Ir para o conteúdo

tempestweb.cli

A implementação do comando tempestwebnew, build, dev, deploy, gen, sync. Documentado para quem quer chamar um comando de dentro do Python ou estender a CLI; para usá-la no terminal, a página de uso é o lugar certo.

Guia com exemplos: Usando a CLI.

tempestweb.cli

tempestweb.cli — the tempestweb command-line tool.

The CLI drives the whole developer loop in typed Python: new scaffolds a runnable project, dev watches and triggers reloads, build emits a mode-specific artifact, and run builds then serves. See docs/plan.md §5.

Public symbols (the parser, every command entrypoint, the config/loader/scaffold helpers) are re-exported here so callers import at the package level rather than reaching into submodules.

BuildError

Bases: RuntimeError

Raised when a build cannot produce a valid artifact.

Source code in tempestweb/cli/commands/build.py
class BuildError(RuntimeError):
    """Raised when a build cannot produce a valid artifact."""

BuildResult dataclass

The outcome of a build.

Attributes:

Name Type Description
mode str

The execution mode that was built ("wasm" or "server").

out_dir Path

The artifact root directory.

files tuple[str, ...]

Artifact-relative paths that were written, in a stable order.

Source code in tempestweb/cli/commands/build.py
@dataclass(slots=True)
class BuildResult:
    """The outcome of a build.

    Attributes:
        mode: The execution mode that was built (``"wasm"`` or ``"server"``).
        out_dir: The artifact root directory.
        files: Artifact-relative paths that were written, in a stable order.
    """

    mode: str
    out_dir: Path
    files: tuple[str, ...] = field(default_factory=tuple)

DeployError

Bases: RuntimeError

Raised when deploy scaffolding cannot proceed.

Source code in tempestweb/cli/commands/deploy.py
class DeployError(RuntimeError):
    """Raised when deploy scaffolding cannot proceed."""

DeployResult dataclass

The outcome of scaffolding deploy files.

Attributes:

Name Type Description
out_dir Path

The directory the files were written to.

files tuple[str, ...]

The relative file names written.

Source code in tempestweb/cli/commands/deploy.py
@dataclass(slots=True)
class DeployResult:
    """The outcome of scaffolding deploy files.

    Attributes:
        out_dir: The directory the files were written to.
        files: The relative file names written.
    """

    out_dir: Path
    files: tuple[str, ...]

DevError

Bases: RuntimeError

Raised when a dev session cannot be started.

Source code in tempestweb/cli/commands/dev.py
class DevError(RuntimeError):
    """Raised when a dev session cannot be started."""

DevSession dataclass

A ready-to-run dev session: watcher + signal + transport, all wired.

Attributes:

Name Type Description
config ProjectConfig

The resolved project config.

mode str

The execution mode for this session.

signal ReloadSignal

The reload hub the watcher triggers.

watcher FileWatcher

The file watcher observing the project root.

transport StubTransport

The reload sink (a :class:StubTransport until T2/T3 land).

Source code in tempestweb/cli/commands/dev.py
@dataclass(slots=True)
class DevSession:
    """A ready-to-run dev session: watcher + signal + transport, all wired.

    Attributes:
        config: The resolved project config.
        mode: The execution mode for this session.
        signal: The reload hub the watcher triggers.
        watcher: The file watcher observing the project root.
        transport: The reload sink (a :class:`StubTransport` until T2/T3 land).
    """

    config: ProjectConfig
    mode: str
    signal: ReloadSignal
    watcher: FileWatcher
    transport: StubTransport

NewError

Bases: RuntimeError

Raised when a project cannot be scaffolded.

Source code in tempestweb/cli/commands/new.py
class NewError(RuntimeError):
    """Raised when a project cannot be scaffolded."""

RunError

Bases: RuntimeError

Raised when a run cannot be prepared.

Source code in tempestweb/cli/commands/run.py
class RunError(RuntimeError):
    """Raised when a run cannot be prepared."""

RunPlan dataclass

A built artifact plus the bind plan for serving it.

Attributes:

Name Type Description
build BuildResult

The artifact produced for this run.

host str

The bind address (127.0.0.1 for local; 0.0.0.0 for LAN).

port int

The bind port.

Source code in tempestweb/cli/commands/run.py
@dataclass(slots=True)
class RunPlan:
    """A built artifact plus the bind plan for serving it.

    Attributes:
        build: The artifact produced for this run.
        host: The bind address (``127.0.0.1`` for local; ``0.0.0.0`` for LAN).
        port: The bind port.
    """

    build: BuildResult
    host: str
    port: int

    @property
    def url(self) -> str:
        """Return the local URL the served app will be reachable at.

        Returns:
            An ``http://host:port`` URL string.
        """
        return f"http://{self.host}:{self.port}"

url property

url: str

Return the local URL the served app will be reachable at.

Returns:

Type Description
str

An http://host:port URL string.

StubTransport dataclass

A transport-agnostic reload sink used until a real transport plugs in.

Records every reload it receives. A real transport (browser reload for Mode A, session restart for Mode B) replaces this by subscribing to the same :class:ReloadSignal.

Attributes:

Name Type Description
mode str

The execution mode this transport stands in for.

reloads list[ReloadEvent]

Every reload event received, in order.

Source code in tempestweb/cli/commands/dev.py
@dataclass(slots=True)
class StubTransport:
    """A transport-agnostic reload sink used until a real transport plugs in.

    Records every reload it receives. A real transport (browser reload for Mode
    A, session restart for Mode B) replaces this by subscribing to the same
    :class:`ReloadSignal`.

    Attributes:
        mode: The execution mode this transport stands in for.
        reloads: Every reload event received, in order.
    """

    mode: str
    reloads: list[ReloadEvent] = field(default_factory=list)

    def on_reload(self, event: ReloadEvent) -> None:
        """Handle a reload event by recording it.

        Args:
            event: The reload event emitted by the signal.
        """
        self.reloads.append(event)

on_reload

on_reload(event: ReloadEvent) -> None

Handle a reload event by recording it.

Parameters:

Name Type Description Default
event ReloadEvent

The reload event emitted by the signal.

required
Source code in tempestweb/cli/commands/dev.py
def on_reload(self, event: ReloadEvent) -> None:
    """Handle a reload event by recording it.

    Args:
        event: The reload event emitted by the signal.
    """
    self.reloads.append(event)

SyncError

Bases: RuntimeError

Raised when tempestweb sync cannot complete.

Source code in tempestweb/cli/commands/sync.py
class SyncError(RuntimeError):
    """Raised when ``tempestweb sync`` cannot complete."""

SyncResult dataclass

Outcome of a tempestweb sync run.

Attributes:

Name Type Description
config_path Path

The tempestweb.toml that was (or would be) written.

modules list[str]

The full [wasm].modules list after the sync.

added list[str]

The module names newly discovered and added this run.

changed bool

Whether the config would change (added is non-empty).

written bool

Whether the config file was actually written (False for a dry run, or when nothing changed).

Source code in tempestweb/cli/commands/sync.py
@dataclass(slots=True)
class SyncResult:
    """Outcome of a ``tempestweb sync`` run.

    Attributes:
        config_path: The ``tempestweb.toml`` that was (or would be) written.
        modules: The full ``[wasm].modules`` list after the sync.
        added: The module names newly discovered and added this run.
        changed: Whether the config would change (``added`` is non-empty).
        written: Whether the config file was actually written (``False`` for a
            dry run, or when nothing changed).
    """

    config_path: Path
    modules: list[str] = field(default_factory=list)
    added: list[str] = field(default_factory=list)
    changed: bool = False
    written: bool = False

ConfigError

Bases: RuntimeError

Raised when a tempestweb.toml is present but invalid.

Source code in tempestweb/cli/config.py
class ConfigError(RuntimeError):
    """Raised when a ``tempestweb.toml`` is present but invalid."""

ProjectConfig dataclass

Resolved configuration for a tempestweb project.

Attributes:

Name Type Description
root Path

The project directory the config was read from.

name str

The project name.

entrypoint str

The project-relative path to the app module.

mode str

The default execution mode ("wasm" or "server").

host str

The default dev/run bind address.

port int

The default dev/run port.

wasm WasmConfig

Mode A build extras (extra packages, bundled modules, static assets, injected scripts). Empty by default.

pwa PwaConfig

Web-App-Manifest overrides. Installable-shaped defaults otherwise.

typing_strictness Strictness

How strictly the quality commands (lint/type/ check) enforce typing — "lenient" | "standard" | "strict". Read from [quality] typing_strictness.

Source code in tempestweb/cli/config.py
@dataclass(slots=True)
class ProjectConfig:
    """Resolved configuration for a tempestweb project.

    Attributes:
        root: The project directory the config was read from.
        name: The project name.
        entrypoint: The project-relative path to the app module.
        mode: The default execution mode (``"wasm"`` or ``"server"``).
        host: The default dev/run bind address.
        port: The default dev/run port.
        wasm: Mode A build extras (extra packages, bundled modules, static
            assets, injected scripts). Empty by default.
        pwa: Web-App-Manifest overrides. Installable-shaped defaults otherwise.
        typing_strictness: How strictly the quality commands (``lint``/``type``/
            ``check``) enforce typing — ``"lenient"`` | ``"standard"`` |
            ``"strict"``. Read from ``[quality] typing_strictness``.
    """

    root: Path
    name: str
    entrypoint: str = "app.py"
    mode: str = "wasm"
    host: str = "127.0.0.1"
    port: int = 8000
    wasm: WasmConfig = field(default_factory=WasmConfig)
    pwa: PwaConfig = field(default_factory=PwaConfig)
    typing_strictness: Strictness = DEFAULT_STRICTNESS

    @property
    def entrypoint_path(self) -> Path:
        """Return the absolute path to the entrypoint module.

        Returns:
            ``root / entrypoint`` resolved to an absolute path.
        """
        return (self.root / self.entrypoint).resolve()

entrypoint_path property

entrypoint_path: Path

Return the absolute path to the entrypoint module.

Returns:

Type Description
Path

root / entrypoint resolved to an absolute path.

LoadedApp dataclass

A successfully loaded project module and its contract callables.

Attributes:

Name Type Description
path Path

The resolved path of the loaded entrypoint module.

module Any

The imported module object.

make_state Callable[[], Any]

The project's make_state callable.

view Callable[[App[Any]], Widget]

The project's view callable.

Source code in tempestweb/cli/loader.py
@dataclass(slots=True)
class LoadedApp:
    """A successfully loaded project module and its contract callables.

    Attributes:
        path: The resolved path of the loaded entrypoint module.
        module: The imported module object.
        make_state: The project's ``make_state`` callable.
        view: The project's ``view`` callable.
    """

    path: Path
    module: Any
    make_state: Callable[[], Any]
    view: Callable[[App[Any]], Widget]

ProjectLoadError

Bases: RuntimeError

Raised when a project module cannot be loaded or is missing its contract.

This covers a missing entrypoint file, an import error inside the module, or the absence of the required make_state / view callables.

Source code in tempestweb/cli/loader.py
class ProjectLoadError(RuntimeError):
    """Raised when a project module cannot be loaded or is missing its contract.

    This covers a missing entrypoint file, an import error inside the module, or
    the absence of the required ``make_state`` / ``view`` callables.
    """

ProjectExistsError

Bases: RuntimeError

Raised when the target directory already exists and is not empty.

Source code in tempestweb/cli/scaffold.py
class ProjectExistsError(RuntimeError):
    """Raised when the target directory already exists and is not empty."""

ScaffoldResult dataclass

The outcome of scaffolding a project.

Attributes:

Name Type Description
root Path

The created project directory.

files tuple[str, ...]

Project-relative paths that were written, in write order.

Source code in tempestweb/cli/scaffold.py
@dataclass(slots=True)
class ScaffoldResult:
    """The outcome of scaffolding a project.

    Attributes:
        root: The created project directory.
        files: Project-relative paths that were written, in write order.
    """

    root: Path
    files: tuple[str, ...]

build_artifact

build_artifact(project_root: str | Path, *, mode: str | None = None, out_dir: str | Path | None = None, clean: bool = True, offline: bool = False, dev: bool = False) -> BuildResult

Build a deployable artifact for mode from a project.

Parameters:

Name Type Description Default
project_root str | Path

The project directory (must contain the entrypoint).

required
mode str | None

"wasm" or "server". Defaults to the project config's mode.

None
out_dir str | Path | None

Where to write the artifact. Defaults to <project_root>/dist/<mode>.

None
clean bool

When True (default), remove an existing out_dir first.

True
offline bool

When True (wasm only), vendor the Pyodide runtime + package wheels into the artifact so it boots fully offline (the service worker precaches them). Requires network at build time to download them. Ignored for server mode.

False
dev bool

When True (the tempestweb dev loop), the wasm/transpile shell skips the caching service worker and injects a cache kill-switch, so every reload serves the freshly rebuilt bundle. Production builds (run / build / deploy) leave it False and keep the caching SW for fast repeat loads.

False

Returns:

Name Type Description
A BuildResult

class:BuildResult describing the artifact.

Raises:

Type Description
BuildError

If the mode is invalid or the project's view fails to render.

Source code in tempestweb/cli/commands/build.py
def build_artifact(
    project_root: str | Path,
    *,
    mode: str | None = None,
    out_dir: str | Path | None = None,
    clean: bool = True,
    offline: bool = False,
    dev: bool = False,
) -> BuildResult:
    """Build a deployable artifact for ``mode`` from a project.

    Args:
        project_root: The project directory (must contain the entrypoint).
        mode: ``"wasm"`` or ``"server"``. Defaults to the project config's mode.
        out_dir: Where to write the artifact. Defaults to
            ``<project_root>/dist/<mode>``.
        clean: When ``True`` (default), remove an existing ``out_dir`` first.
        offline: When ``True`` (wasm only), vendor the Pyodide runtime + package
            wheels into the artifact so it boots fully offline (the service worker
            precaches them). Requires network *at build time* to download them.
            Ignored for server mode.
        dev: When ``True`` (the ``tempestweb dev`` loop), the wasm/transpile shell
            skips the caching service worker and injects a cache kill-switch, so
            every reload serves the freshly rebuilt bundle. Production builds
            (``run`` / ``build`` / ``deploy``) leave it ``False`` and keep the
            caching SW for fast repeat loads.

    Returns:
        A :class:`BuildResult` describing the artifact.

    Raises:
        BuildError: If the mode is invalid or the project's view fails to render.
    """
    config: ProjectConfig = load_config(project_root)
    resolved_mode = mode or config.mode
    if resolved_mode not in VALID_MODES:
        raise BuildError(
            f"invalid mode {resolved_mode!r}; expected one of {VALID_MODES}"
        )

    # A build is only valid if the project actually renders an initial tree.
    try:
        loaded = load_app(config.entrypoint_path)
        render_initial_tree(loaded)
    except Exception as exc:  # noqa: BLE001 - turn any load/render error into BuildError
        raise BuildError(f"project failed to build: {exc}") from exc

    out = (
        Path(out_dir).resolve()
        if out_dir is not None
        else (config.root / "dist" / resolved_mode).resolve()
    )
    if clean and out.exists():
        shutil.rmtree(out)
    out.mkdir(parents=True, exist_ok=True)

    client = _client_dir()
    app_source = config.entrypoint_path.read_text(encoding="utf-8")
    manifest = _manifest_options(config)

    if resolved_mode == "wasm":
        files = _build_wasm(
            out,
            client,
            config.name,
            app_source,
            offline=offline,
            project_root=config.root,
            wasm=config.wasm,
            manifest=manifest,
            dev=dev,
            with_manifest=config.pwa.manifest,
            with_service_worker=config.pwa.service_worker,
        )
    elif resolved_mode == "transpile":
        files = _build_transpile(
            out,
            client,
            config.name,
            app_source,
            config.entrypoint_path.name,
            manifest=manifest,
            dev=dev,
            with_manifest=config.pwa.manifest,
            with_service_worker=config.pwa.service_worker,
        )
    else:
        files = _build_server(out, client, config.name, app_source)

    return BuildResult(mode=resolved_mode, out_dir=out, files=files)

create_dev_session

create_dev_session(project_root: str | Path, *, mode: str | None = None, verify: bool = True) -> DevSession

Build a wired dev session for a project without starting the watch loop.

The session is fully connected — triggering the signal (or feeding the watcher a change batch) reaches the transport — but the blocking file-watch loop is started separately via await session.watcher.run(). Splitting construction from the loop keeps the session unit-testable.

Parameters:

Name Type Description Default
project_root str | Path

The project directory.

required
mode str | None

"wasm" or "server". Defaults to the project config's mode.

None
verify bool

When True (default), confirm the entrypoint loads before wiring the session.

True

Returns:

Type Description
DevSession

A wired :class:DevSession.

Raises:

Type Description
DevError

If the mode is invalid or (when verifying) the project fails to load.

Source code in tempestweb/cli/commands/dev.py
def create_dev_session(
    project_root: str | Path,
    *,
    mode: str | None = None,
    verify: bool = True,
) -> DevSession:
    """Build a wired dev session for a project without starting the watch loop.

    The session is fully connected — triggering the signal (or feeding the
    watcher a change batch) reaches the transport — but the blocking file-watch
    loop is started separately via ``await session.watcher.run()``. Splitting
    construction from the loop keeps the session unit-testable.

    Args:
        project_root: The project directory.
        mode: ``"wasm"`` or ``"server"``. Defaults to the project config's mode.
        verify: When ``True`` (default), confirm the entrypoint loads before
            wiring the session.

    Returns:
        A wired :class:`DevSession`.

    Raises:
        DevError: If the mode is invalid or (when verifying) the project fails to
            load.
    """
    config = load_config(project_root)
    resolved_mode = mode or config.mode
    if resolved_mode not in VALID_MODES:
        raise DevError(f"invalid mode {resolved_mode!r}; expected one of {VALID_MODES}")

    if verify:
        entrypoint = config.entrypoint_path
        if not Path(entrypoint).is_file():
            raise DevError(f"entrypoint not found: {entrypoint}")
        try:
            load_app(entrypoint)
        except Exception as exc:  # noqa: BLE001 - normalize to DevError
            raise DevError(str(exc)) from exc

    signal = ReloadSignal()
    transport = StubTransport(mode=resolved_mode)
    signal.subscribe(transport.on_reload)
    watcher = FileWatcher(config.root, signal)

    return DevSession(
        config=config,
        mode=resolved_mode,
        signal=signal,
        watcher=watcher,
        transport=transport,
    )

create_project

create_project(name: str, *, parent: str | Path = '.', force: bool = False, verify: bool = True, template: str = 'default') -> ScaffoldResult

Scaffold a new project and optionally verify it renders.

Parameters:

Name Type Description Default
name str

The project name / directory.

required
parent str | Path

The directory to create the project inside. Defaults to the cwd.

'.'
force bool

Overwrite a non-empty target directory when True.

False
verify bool

When True (default), load the scaffolded app.py and render its initial view to confirm the project is runnable.

True
template str

The scaffold template — "default" or "pwa".

'default'

Returns:

Name Type Description
The ScaffoldResult

class:ScaffoldResult describing the created tree.

Raises:

Type Description
NewError

If the name is empty or the scaffold fails verification.

Source code in tempestweb/cli/commands/new.py
def create_project(
    name: str,
    *,
    parent: str | Path = ".",
    force: bool = False,
    verify: bool = True,
    template: str = "default",
) -> ScaffoldResult:
    """Scaffold a new project and optionally verify it renders.

    Args:
        name: The project name / directory.
        parent: The directory to create the project inside. Defaults to the cwd.
        force: Overwrite a non-empty target directory when ``True``.
        verify: When ``True`` (default), load the scaffolded ``app.py`` and
            render its initial view to confirm the project is runnable.
        template: The scaffold template — ``"default"`` or ``"pwa"``.

    Returns:
        The :class:`ScaffoldResult` describing the created tree.

    Raises:
        NewError: If the name is empty or the scaffold fails verification.
    """
    if not name or not name.strip():
        raise NewError("project name must not be empty")

    try:
        result = scaffold_project(name, parent=parent, force=force, template=template)
    except Exception as exc:  # noqa: BLE001 - normalize to NewError for the CLI
        raise NewError(str(exc)) from exc

    if verify:
        entrypoint = result.root / "app.py"
        try:
            loaded = load_app(entrypoint)
            render_initial_tree(loaded)
        except Exception as exc:  # noqa: BLE001 - scaffold must be runnable
            raise NewError(f"scaffolded project is not runnable: {exc}") from exc

    return result

prepare_run

prepare_run(project_root: str | Path, *, mode: str | None = None, host: str | None = None, port: int | None = None, offline: bool = False) -> RunPlan

Build the artifact and compute the bind plan for serving it.

Parameters:

Name Type Description Default
project_root str | Path

The project directory.

required
mode str | None

"wasm" or "server". Defaults to the project config's mode.

None
host str | None

Override the bind address. Defaults to the project config's host.

None
port int | None

Override the bind port. Defaults to the project config's port.

None
offline bool

When True (wasm), vendor an offline-capable Pyodide into the built bundle. See :func:~tempestweb.cli.commands.build.build_artifact.

False

Returns:

Name Type Description
A RunPlan

class:RunPlan with the built artifact and bind address.

Raises:

Type Description
RunError

If the build fails.

Source code in tempestweb/cli/commands/run.py
def prepare_run(
    project_root: str | Path,
    *,
    mode: str | None = None,
    host: str | None = None,
    port: int | None = None,
    offline: bool = False,
) -> RunPlan:
    """Build the artifact and compute the bind plan for serving it.

    Args:
        project_root: The project directory.
        mode: ``"wasm"`` or ``"server"``. Defaults to the project config's mode.
        host: Override the bind address. Defaults to the project config's host.
        port: Override the bind port. Defaults to the project config's port.
        offline: When ``True`` (wasm), vendor an offline-capable Pyodide into the
            built bundle. See :func:`~tempestweb.cli.commands.build.build_artifact`.

    Returns:
        A :class:`RunPlan` with the built artifact and bind address.

    Raises:
        RunError: If the build fails.
    """
    config: ProjectConfig = load_config(project_root)
    try:
        build = build_artifact(project_root, mode=mode, offline=offline)
    except Exception as exc:  # noqa: BLE001 - normalize to RunError
        raise RunError(str(exc)) from exc

    return RunPlan(
        build=build,
        host=host or config.host,
        port=port if port is not None else config.port,
    )

render_deploy_files

render_deploy_files(root: str | Path, *, server_name: str = '_', tls: bool = False, replicas: int = 1, sticky: bool = True, port: int | None = None) -> dict[str, str]

Render every deploy file's contents without touching disk.

Parameters:

Name Type Description Default
root str | Path

The project directory (read for the tempestweb.toml port/name).

required
server_name str

The nginx server_name (default _ = any host).

'_'
tls bool

When True, emit a 443 TLS server block + HTTP→HTTPS redirect.

False
replicas int

Number of app upstream servers to list.

1
sticky bool

Emit ip_hash (sticky sessions); set False with a RedisSessionRouter for round-robin SSE scale-out.

True
port int | None

Override the app port (defaults to the config's port).

None

Returns:

Type Description
dict[str, str]

A mapping of file name to contents covering :data:DEPLOY_FILES.

Raises:

Type Description
DeployError

If replicas is below 1.

Source code in tempestweb/cli/commands/deploy.py
def render_deploy_files(
    root: str | Path,
    *,
    server_name: str = "_",
    tls: bool = False,
    replicas: int = 1,
    sticky: bool = True,
    port: int | None = None,
) -> dict[str, str]:
    """Render every deploy file's contents without touching disk.

    Args:
        root: The project directory (read for the ``tempestweb.toml`` port/name).
        server_name: The nginx ``server_name`` (default ``_`` = any host).
        tls: When ``True``, emit a 443 TLS server block + HTTP→HTTPS redirect.
        replicas: Number of ``app`` upstream servers to list.
        sticky: Emit ``ip_hash`` (sticky sessions); set ``False`` with a
            ``RedisSessionRouter`` for round-robin SSE scale-out.
        port: Override the app port (defaults to the config's port).

    Returns:
        A mapping of file name to contents covering :data:`DEPLOY_FILES`.

    Raises:
        DeployError: If ``replicas`` is below 1.
    """
    if replicas < 1:
        raise DeployError("replicas must be >= 1")
    config = load_config(root)
    app_port = port if port is not None else config.port
    return {
        "nginx.conf": _nginx_conf(
            port=app_port,
            server_name=server_name,
            tls=tls,
            replicas=replicas,
            sticky=sticky,
        ),
        "Dockerfile": _dockerfile(port=app_port),
        "docker-compose.yml": _compose(port=app_port, tls=tls, replicas=replicas),
        "DEPLOY.md": _deploy_md(
            name=config.name, port=app_port, server_name=server_name, tls=tls
        ),
    }

scaffold_deploy

scaffold_deploy(root: str | Path, *, out: str | Path | None = None, server_name: str = '_', tls: bool = False, replicas: int = 1, sticky: bool = True, force: bool = False) -> DeployResult

Write the deploy files into out (default <root>/deploy).

Parameters:

Name Type Description Default
root str | Path

The project directory.

required
out str | Path | None

The output directory (default <root>/deploy).

None
server_name str

The nginx server_name.

'_'
tls bool

Emit a TLS (443) server block.

False
replicas int

Upstream app replica count.

1
sticky bool

Emit ip_hash (default); False for round-robin.

True
force bool

Overwrite existing files instead of refusing.

False

Returns:

Name Type Description
A DeployResult

class:DeployResult.

Raises:

Type Description
DeployError

If a target file exists and force is False, or an argument is invalid.

Source code in tempestweb/cli/commands/deploy.py
def scaffold_deploy(
    root: str | Path,
    *,
    out: str | Path | None = None,
    server_name: str = "_",
    tls: bool = False,
    replicas: int = 1,
    sticky: bool = True,
    force: bool = False,
) -> DeployResult:
    """Write the deploy files into ``out`` (default ``<root>/deploy``).

    Args:
        root: The project directory.
        out: The output directory (default ``<root>/deploy``).
        server_name: The nginx ``server_name``.
        tls: Emit a TLS (443) server block.
        replicas: Upstream ``app`` replica count.
        sticky: Emit ``ip_hash`` (default); ``False`` for round-robin.
        force: Overwrite existing files instead of refusing.

    Returns:
        A :class:`DeployResult`.

    Raises:
        DeployError: If a target file exists and ``force`` is ``False``, or an
            argument is invalid.
    """
    root_path = Path(root).resolve()
    out_dir = Path(out).resolve() if out is not None else (root_path / "deploy")
    contents = render_deploy_files(
        root_path,
        server_name=server_name,
        tls=tls,
        replicas=replicas,
        sticky=sticky,
    )
    out_dir.mkdir(parents=True, exist_ok=True)
    for name in DEPLOY_FILES:
        target = out_dir / name
        if target.exists() and not force:
            raise DeployError(f"{target} already exists (pass force=True to overwrite)")
        target.write_text(contents[name], encoding="utf-8")
    return DeployResult(out_dir=out_dir, files=DEPLOY_FILES)

serve_run

serve_run(plan: RunPlan) -> None

Serve a built artifact according to its bind plan (blocking).

For server mode this imports the artifact's server.py — the real FastAPI WS/SSE host — and runs it under uvicorn. For wasm mode it serves the static bundle over the dev HTTP app. Either way the call binds plan.host:plan.port and blocks until stopped (Ctrl-C).

Parameters:

Name Type Description Default
plan RunPlan

The run plan produced by :func:prepare_run.

required

Raises:

Type Description
RunError

If the built server (server mode) cannot be imported.

Source code in tempestweb/cli/commands/run.py
def serve_run(plan: RunPlan) -> None:
    """Serve a built artifact according to its bind plan (blocking).

    For **server** mode this imports the artifact's ``server.py`` — the real
    FastAPI WS/SSE host — and runs it under uvicorn. For **wasm** mode it serves
    the static bundle over the dev HTTP app. Either way the call binds
    ``plan.host:plan.port`` and blocks until stopped (Ctrl-C).

    Args:
        plan: The run plan produced by :func:`prepare_run`.

    Raises:
        RunError: If the built server (server mode) cannot be imported.
    """
    if plan.build.mode == "server":
        server = _load_artifact_server(plan.build.out_dir)
        server.run(plan.host, plan.port)
        return
    # wasm: static-host the bundle (no livereload — that is `dev`'s job).
    try:
        from tempestweb.devserver.http import create_dev_app, serve
    except ImportError as exc:  # noqa: TRY003 - actionable install hint
        raise RunError(
            "serving Mode A needs the 'server' extra (Starlette + uvicorn). "
            "Install it with: uv add 'tempestweb[server]' "
            "(or pip install 'tempestweb[server]'). The built wasm artifact "
            "itself never embeds a server — this is only for local serving."
        ) from exc

    serve(create_dev_app(plan.build.out_dir), plan.host, plan.port)

sync_modules

sync_modules(path: str | Path, *, dry_run: bool = False) -> SyncResult

Fill [wasm].modules from the project's installed pure-Python deps.

Reads [project.dependencies] from the project's pyproject.toml, keeps the dependencies that are installed and pure-Python (excluding the framework and anything already under [wasm].packages), and adds their import names to [wasm].modules — preserving any existing entries. Idempotent.

Parameters:

Name Type Description Default
path str | Path

The project directory (the one holding tempestweb.toml).

required
dry_run bool

When True, compute the result but do not write the file.

False

Returns:

Name Type Description
A SyncResult

class:SyncResult describing the final module list and what changed.

Raises:

Type Description
SyncError

If there is no tempestweb.toml, the pyproject.toml is malformed, or the write back-end (tomlkit) is missing.

Source code in tempestweb/cli/commands/sync.py
def sync_modules(path: str | Path, *, dry_run: bool = False) -> SyncResult:
    """Fill ``[wasm].modules`` from the project's installed pure-Python deps.

    Reads ``[project.dependencies]`` from the project's ``pyproject.toml``, keeps
    the dependencies that are installed and pure-Python (excluding the framework
    and anything already under ``[wasm].packages``), and adds their import names
    to ``[wasm].modules`` — preserving any existing entries. Idempotent.

    Args:
        path: The project directory (the one holding ``tempestweb.toml``).
        dry_run: When ``True``, compute the result but do not write the file.

    Returns:
        A :class:`SyncResult` describing the final module list and what changed.

    Raises:
        SyncError: If there is no ``tempestweb.toml``, the ``pyproject.toml`` is
            malformed, or the write back-end (``tomlkit``) is missing.
    """
    config = load_config(path)
    config_path = config.root / "tempestweb.toml"
    if not config_path.is_file():
        raise SyncError(
            f"no tempestweb.toml in {config.root} — run `tempestweb new` first"
        )

    # Normalize the framework names too (not just the config packages) so the
    # exclude set always matches the normalized dependency/module names compared
    # against it — robust even if a name here is added in non-canonical form.
    exclude = {
        *(_normalize(name) for name in _FRAMEWORK),
        *(_normalize(package) for package in config.wasm.packages),
    }
    discovered = _discover_modules(config.root, exclude)

    existing = list(config.wasm.modules)
    added = [module for module in discovered if module not in existing]
    modules = existing + added
    changed = bool(added)

    written = False
    if changed and not dry_run:
        _write_modules(config_path, modules)
        written = True

    return SyncResult(
        config_path=config_path,
        modules=modules,
        added=added,
        changed=changed,
        written=written,
    )

load_config

load_config(root: str | Path) -> ProjectConfig

Load tempestweb.toml from a project root, falling back to defaults.

Parameters:

Name Type Description Default
root str | Path

The project directory.

required

Returns:

Name Type Description
A ProjectConfig

class:ProjectConfig. If no tempestweb.toml exists, every field

ProjectConfig

takes its default and name is the directory name.

Raises:

Type Description
ConfigError

If a tempestweb.toml exists but is malformed, or names an invalid mode.

Source code in tempestweb/cli/config.py
def load_config(root: str | Path) -> ProjectConfig:
    """Load ``tempestweb.toml`` from a project root, falling back to defaults.

    Args:
        root: The project directory.

    Returns:
        A :class:`ProjectConfig`. If no ``tempestweb.toml`` exists, every field
        takes its default and ``name`` is the directory name.

    Raises:
        ConfigError: If a ``tempestweb.toml`` exists but is malformed, or names
            an invalid mode.
    """
    root_path = Path(root).resolve()
    config_path = root_path / "tempestweb.toml"
    name = root_path.name

    if not config_path.is_file():
        return ProjectConfig(root=root_path, name=name)

    try:
        raw: dict[str, Any] = tomllib.loads(config_path.read_text(encoding="utf-8"))
    except tomllib.TOMLDecodeError as exc:
        raise ConfigError(f"invalid {config_path}: {exc}") from exc

    project = raw.get("project", {})
    dev = raw.get("dev", {})
    mode = str(dev.get("mode", "wasm"))
    if mode not in VALID_MODES:
        raise ConfigError(
            f"invalid mode {mode!r} in {config_path}; expected one of {VALID_MODES}"
        )

    quality = raw.get("quality", {})
    strictness = str(quality.get("typing_strictness", DEFAULT_STRICTNESS))
    if strictness not in VALID_STRICTNESS:
        raise ConfigError(
            f"invalid typing_strictness {strictness!r} in {config_path}; "
            f"expected one of {sorted(VALID_STRICTNESS)}"
        )

    return ProjectConfig(
        root=root_path,
        name=str(project.get("name", name)),
        entrypoint=str(project.get("entrypoint", "app.py")),
        mode=mode,
        host=str(dev.get("host", "127.0.0.1")),
        port=int(dev.get("port", 8000)),
        wasm=_parse_wasm(raw.get("wasm", {}), config_path),
        pwa=_parse_pwa(raw.get("pwa", {}), config_path),
        typing_strictness=cast("Strictness", strictness),
    )

load_app

load_app(entrypoint: str | Path) -> LoadedApp

Import a project entrypoint and validate its public contract.

Parameters:

Name Type Description Default
entrypoint str | Path

Path to the project's app.py (or any module exposing make_state and view).

required

Returns:

Name Type Description
A LoadedApp

class:LoadedApp bundling the module and its contract callables.

Raises:

Type Description
ProjectLoadError

If the file does not exist, fails to import, or does not expose callable make_state and view attributes.

Source code in tempestweb/cli/loader.py
def load_app(entrypoint: str | Path) -> LoadedApp:
    """Import a project entrypoint and validate its public contract.

    Args:
        entrypoint: Path to the project's ``app.py`` (or any module exposing
            ``make_state`` and ``view``).

    Returns:
        A :class:`LoadedApp` bundling the module and its contract callables.

    Raises:
        ProjectLoadError: If the file does not exist, fails to import, or does
            not expose callable ``make_state`` and ``view`` attributes.
    """
    path = Path(entrypoint).resolve()
    if not path.is_file():
        raise ProjectLoadError(f"entrypoint not found: {path}")

    # Put the project root on sys.path so the entrypoint can import sibling
    # modules/packages it ships (declared under ``[wasm].modules`` and bundled
    # alongside ``app.py``). Without this a multi-module project fails to import.
    project_root = str(path.parent)
    if project_root not in sys.path:
        sys.path.insert(0, project_root)

    # Make the synthetic module name unique per resolved path so loading several
    # projects (each with its own ``app.py``) in one process never reuses a stale
    # module object from ``sys.modules``.
    digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
    module_name = f"tempestweb_app_{path.stem}_{digest}"
    spec = importlib.util.spec_from_file_location(module_name, path)
    if spec is None or spec.loader is None:
        raise ProjectLoadError(f"cannot build an import spec for {path}")

    module = importlib.util.module_from_spec(spec)
    # Register before exec so `@dataclass` (which resolves field types via
    # ``sys.modules[cls.__module__]`` under ``from __future__ import annotations``)
    # and any decorator that inspects the defining module work correctly.
    sys.modules[module_name] = module
    try:
        spec.loader.exec_module(module)
    except Exception as exc:  # noqa: BLE001 - surface any import-time failure
        sys.modules.pop(module_name, None)
        raise ProjectLoadError(f"failed to import {path}: {exc}") from exc

    make_state = getattr(module, "make_state", None)
    view = getattr(module, "view", None)
    if not callable(make_state):
        raise ProjectLoadError(f"{path} must define a callable `make_state`")
    if not callable(view):
        raise ProjectLoadError(f"{path} must define a callable `view`")

    return LoadedApp(path=path, module=module, make_state=make_state, view=view)

render_initial_tree

render_initial_tree(loaded: LoadedApp) -> Node

Build the project's initial widget tree into a core IR node.

This is the cheapest possible proof that a project is runnable: it builds the initial state, calls view with a minimal :class:~tempest_core.App handle, and reconciles the result into a :class:~tempest_core.Node. No transport, browser or server is involved.

Parameters:

Name Type Description Default
loaded LoadedApp

A project loaded via :func:load_app.

required

Returns:

Type Description
Node

The reconciled root :class:~tempest_core.Node of the initial view.

Raises:

Type Description
ProjectLoadError

If view does not return a widget or building the tree fails.

Source code in tempestweb/cli/loader.py
def render_initial_tree(loaded: LoadedApp) -> Node:
    """Build the project's initial widget tree into a core IR node.

    This is the cheapest possible proof that a project is runnable: it builds the
    initial state, calls ``view`` with a minimal :class:`~tempest_core.App`
    handle, and reconciles the result into a :class:`~tempest_core.Node`. No
    transport, browser or server is involved.

    Args:
        loaded: A project loaded via :func:`load_app`.

    Returns:
        The reconciled root :class:`~tempest_core.Node` of the initial view.

    Raises:
        ProjectLoadError: If ``view`` does not return a widget or building the
            tree fails.
    """
    try:
        state = loaded.make_state()
        app: App[Any] = App(
            state=state,
            view=loaded.view,
            apply_patches=lambda _patches: None,
        )
        widget = loaded.view(app)
        return build(widget)
    except Exception as exc:  # noqa: BLE001 - surface any make_state/render failure
        raise ProjectLoadError(
            f"failed to render the initial view of {loaded.path}: {exc}"
        ) from exc

build_parser

build_parser() -> argparse.ArgumentParser

Build the top-level argument parser.

Returns:

Type Description
ArgumentParser

The configured parser with the new/dev/build/run commands.

Source code in tempestweb/cli/main.py
def build_parser() -> argparse.ArgumentParser:
    """Build the top-level argument parser.

    Returns:
        The configured parser with the ``new``/``dev``/``build``/``run`` commands.
    """
    parser = argparse.ArgumentParser(
        prog="tempestweb",
        description="Build web apps in typed Python (WASM + server + transpile modes).",
        epilog=_TOP_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "-V",
        "--version",
        action="version",
        version=f"tempestweb {_package_version()}",
        help="Show the tempestweb version and exit.",
    )
    sub = parser.add_subparsers(dest="command", required=False)

    new = sub.add_parser("new", help="Scaffold a new tempestweb app.")
    new.add_argument(
        "name",
        help="Project name / directory. Use '.' to scaffold into the current "
        "directory (named after it).",
    )
    new.add_argument(
        "--into",
        default=".",
        help="Parent directory to create the project inside (default: cwd).",
    )
    new.add_argument(
        "--force",
        action="store_true",
        help="Write into an existing non-empty directory.",
    )
    new.add_argument(
        "--template",
        choices=["default", "pwa"],
        default="default",
        help="Scaffold template: default (two-mode counter) or pwa "
        "(Mode C installable/offline PWA).",
    )
    new.add_argument(
        "--no-verify",
        dest="verify",
        action="store_false",
        help="Skip rendering the scaffold to prove it is runnable.",
    )

    dev = sub.add_parser(
        "dev",
        help="Run the app locally (build + serve; watch + reload).",
        description="Build and serve the app locally in any mode. The static "
        "modes (wasm, transpile) get browser livereload; server mode (Mode B) "
        "runs the built FastAPI host under uvicorn and restarts on change.",
    )
    dev.add_argument(
        "--mode",
        choices=["wasm", "server", "transpile"],
        default=None,
        help="Execution mode to serve: wasm (Mode A), server (Mode B) or "
        "transpile (Mode C). Defaults to [dev].mode in tempestweb.toml (else wasm).",
    )
    dev.add_argument(
        "--path",
        default=".",
        help="Project directory to watch (default: cwd).",
    )
    dev.add_argument("--host", default=None, help="Override the bind address.")
    dev.add_argument("--port", type=int, default=None, help="Override the bind port.")

    build = sub.add_parser("build", help="Build a deployable artifact.")
    build.add_argument(
        "--mode",
        choices=["wasm", "server", "transpile"],
        default=None,
        help="wasm = static bundle (Pyodide); server = FastAPI app; "
        "transpile = static bundle of native JS. Defaults to [dev].mode in "
        "tempestweb.toml (else wasm).",
    )
    build.add_argument(
        "--path",
        default=".",
        help="Project directory to build (default: cwd).",
    )
    build.add_argument(
        "--out",
        default=None,
        help="Artifact output directory (default: <project>/dist/<mode>).",
    )
    build.add_argument(
        "--offline",
        action="store_true",
        help="Vendor the Pyodide runtime + wheels so wasm boots offline "
        "(downloads them at build time).",
    )

    run = sub.add_parser(
        "run",
        help="Serve the app as built — no file watching (production-like).",
        description="Build the app once and serve it, without the dev watcher or "
        "livereload. Use this for a production-like local run (and it is what the "
        "generated deploy Dockerfile runs); use `dev` while developing.",
    )
    run.add_argument(
        "--mode",
        choices=["wasm", "server", "transpile"],
        default=None,
        help="Execution mode. Defaults to [dev].mode in tempestweb.toml (else wasm).",
    )
    run.add_argument(
        "--path",
        default=".",
        help="Project directory to build and serve (default: cwd).",
    )
    run.add_argument("--host", default=None, help="Override the bind address.")
    run.add_argument(
        "--port",
        type=int,
        default=None,
        help="Override the bind port.",
    )
    run.add_argument(
        "--offline",
        action="store_true",
        help="Build the wasm bundle with a vendored, offline-capable Pyodide.",
    )

    sync = sub.add_parser(
        "sync",
        help="Fill [wasm].modules from the installed pure-Python dependencies.",
    )
    sync.add_argument(
        "--path",
        default=".",
        help="Project directory to sync (default: cwd).",
    )
    sync.add_argument(
        "--dry-run",
        action="store_true",
        help="Show what would be added without writing tempestweb.toml.",
    )

    deploy = sub.add_parser(
        "deploy",
        help="Scaffold production deploy files (nginx + Docker + guide).",
    )
    deploy.add_argument("--path", default=".", help="Project directory (default: cwd).")
    deploy.add_argument(
        "--out",
        default=None,
        help="Output directory for the deploy files (default: <project>/deploy).",
    )
    deploy.add_argument(
        "--server-name",
        default="_",
        help="nginx server_name (your domain; default: _ = any host).",
    )
    deploy.add_argument(
        "--tls",
        action="store_true",
        help="Emit a TLS (443) server block + HTTP->HTTPS redirect.",
    )
    deploy.add_argument(
        "--replicas",
        type=int,
        default=1,
        help="Number of app upstream replicas in nginx (default: 1).",
    )
    deploy.add_argument(
        "--no-sticky",
        dest="sticky",
        action="store_false",
        help="Drop ip_hash (use with a RedisSessionRouter for SSE scale-out).",
    )
    deploy.add_argument(
        "--force",
        action="store_true",
        help="Overwrite existing deploy files.",
    )

    vapid = sub.add_parser(
        "vapid",
        help="Generate a VAPID keypair for WebPush.",
    )
    vapid.add_argument(
        "--env",
        action="store_true",
        help="Print as VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY env lines.",
    )

    gen = sub.add_parser(
        "gen",
        help="Generate code from a spec (currently: `gen api` from OpenAPI).",
    )
    gen_sub = gen.add_subparsers(dest="gen_target", required=True)
    gen_api = gen_sub.add_parser(
        "api",
        help="Generate a typed client (dataclasses + services) from OpenAPI.",
    )
    gen_api.add_argument(
        "source",
        help="OpenAPI source: a file path or an http(s) URL to openapi.json.",
    )
    gen_api.add_argument(
        "--out",
        default="api",
        help="Output directory for the generated client (default: ./api).",
    )

    _add_quality_parsers(sub)

    return parser

render_files

render_files(name: str, *, template: str = 'default') -> dict[str, str]

Render every scaffolded file's contents without touching disk.

Parameters:

Name Type Description Default
name str

The project name (used in config and README).

required
template str

The scaffold template — "default" (a two-mode counter) or "pwa" (a Mode C native-JS Progressive Web App).

'default'

Returns:

Type Description
dict[str, str]

A mapping of project-relative path to file contents, covering exactly

dict[str, str]

data:PROJECT_FILES.

Raises:

Type Description
UnknownTemplateError

If template is not one of :data:TEMPLATES.

Source code in tempestweb/cli/scaffold.py
def render_files(name: str, *, template: str = "default") -> dict[str, str]:
    """Render every scaffolded file's contents without touching disk.

    Args:
        name: The project name (used in config and README).
        template: The scaffold template — ``"default"`` (a two-mode counter) or
            ``"pwa"`` (a Mode C native-JS Progressive Web App).

    Returns:
        A mapping of project-relative path to file contents, covering exactly
        :data:`PROJECT_FILES`.

    Raises:
        UnknownTemplateError: If ``template`` is not one of :data:`TEMPLATES`.
    """
    if template not in TEMPLATES:
        raise UnknownTemplateError(
            f"unknown template {template!r}; expected one of {TEMPLATES}"
        )
    if template == "pwa":
        return {
            "app.py": _pwa_app_py(),
            "tempestweb.toml": _pwa_toml(name),
            "README.md": _pwa_readme(name),
            ".gitignore": _gitignore(),
        }
    return {
        "app.py": _app_py(),
        "tempestweb.toml": _tempestweb_toml(name),
        "README.md": _readme(name),
        ".gitignore": _gitignore(),
    }

scaffold_project

scaffold_project(name: str, *, parent: str | Path = '.', force: bool = False, template: str = 'default') -> ScaffoldResult

Create a new runnable project tree under parent/name.

Passing name="." scaffolds in place into parent (the current directory) instead of creating a subdirectory, and derives the project name from that directory's basename. In-place scaffolding refuses to clobber an existing scaffold file (app.py / tempestweb.toml / …) unless force is set, but tolerates other pre-existing files in the directory.

Parameters:

Name Type Description Default
name str

The project name / directory. "." means scaffold into parent itself, naming the project after that directory.

required
parent str | Path

The directory to create the project inside. Defaults to the cwd.

'.'
force bool

When True, write into an existing non-empty directory (or overwrite existing scaffold files) instead of refusing.

False
template str

The scaffold template ("default" or "pwa").

'default'

Returns:

Name Type Description
A ScaffoldResult

class:ScaffoldResult describing the created tree.

Raises:

Type Description
ProjectExistsError

If the target directory exists and is non-empty and force is False (for a named project), or already contains scaffold files (for in-place ".").

UnknownTemplateError

If template is unknown.

Source code in tempestweb/cli/scaffold.py
def scaffold_project(
    name: str,
    *,
    parent: str | Path = ".",
    force: bool = False,
    template: str = "default",
) -> ScaffoldResult:
    """Create a new runnable project tree under ``parent/name``.

    Passing ``name="."`` scaffolds **in place** into ``parent`` (the current
    directory) instead of creating a subdirectory, and derives the project name
    from that directory's basename. In-place scaffolding refuses to clobber an
    existing scaffold file (``app.py`` / ``tempestweb.toml`` / …) unless ``force``
    is set, but tolerates other pre-existing files in the directory.

    Args:
        name: The project name / directory. ``"."`` means scaffold into
            ``parent`` itself, naming the project after that directory.
        parent: The directory to create the project inside. Defaults to the cwd.
        force: When ``True``, write into an existing non-empty directory (or
            overwrite existing scaffold files) instead of refusing.
        template: The scaffold template (``"default"`` or ``"pwa"``).

    Returns:
        A :class:`ScaffoldResult` describing the created tree.

    Raises:
        ProjectExistsError: If the target directory exists and is non-empty and
            ``force`` is ``False`` (for a named project), or already contains
            scaffold files (for in-place ``"."``).
        UnknownTemplateError: If ``template`` is unknown.
    """
    in_place = name == "."
    if in_place:
        root = Path(parent).resolve()
        project_name = root.name
        conflicts = [rel for rel in PROJECT_FILES if (root / rel).exists()]
        if conflicts and not force:
            raise ProjectExistsError(
                f"{root} already contains {', '.join(conflicts)} "
                "(pass force=True to overwrite)"
            )
    else:
        root = (Path(parent) / name).resolve()
        project_name = name
        if root.exists() and any(root.iterdir()) and not force:
            raise ProjectExistsError(
                f"{root} already exists and is not empty (pass force=True to overwrite)"
            )

    contents = render_files(project_name, template=template)
    root.mkdir(parents=True, exist_ok=True)
    for rel in PROJECT_FILES:
        target = root / rel
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(contents[rel], encoding="utf-8")

    return ScaffoldResult(root=root, files=PROJECT_FILES)