Skip to content

tempestweb.transpile

The Mode C compiler: it reads the typed-Python app layer and emits native JavaScript. TranspileError carries file:line when the code leaves the supported subset — the error you will meet before any other when porting an app to a static bundle.

Guide with examples: Mode C — transpile.

tempestweb.transpile

Mode C transpiler — typed Python app layer → native JavaScript.

Transcribes a tempestweb app module (state dataclasses + view + handlers) into an ES module that runs on the native JS runtime (client/transpile/runtime.js), with zero Python at execution time. See docs/modo-c-transpile.md.

TranspileError

Bases: Exception

A Python construct fell outside the transpilable subset.

Carries the source location so the message reads like a compiler diagnostic (file:line: <what> is not supported), in the spirit of mypy --strict.

Source code in tempestweb/transpile/errors.py
class TranspileError(Exception):
    """A Python construct fell outside the transpilable subset.

    Carries the source location so the message reads like a compiler diagnostic
    (``file:line: <what> is not supported``), in the spirit of ``mypy --strict``.
    """

    def __init__(
        self, message: str, node: ast.AST | None, filename: str = "<source>"
    ) -> None:
        """Initialize the error.

        Args:
            message: What is unsupported and, ideally, why.
            node: The offending AST node (its ``lineno`` locates the
                diagnostic), or ``None`` when the diagnostic is about the module
                as a whole and no node carries the blame — the line then reads
                ``0``, which is still better than losing the message.
            filename: The source file name, for the ``file:line`` prefix.
        """
        line: int = getattr(node, "lineno", 0)
        self.filename: str = filename
        self.lineno: int = line
        super().__init__(f"{filename}:{line}: {message}")

LocalModule dataclass

One project module reachable from the entrypoint.

Attributes:

Name Type Description
name str

The module's dotted name relative to the project root ("api.tasks.service"). The entrypoint's own name is included.

path Path

The absolute path to the module's source file.

source str

The module's source text.

tree Module

The parsed module.

classes frozenset[str]

The names of the classes the module declares at top level, needed by an importing module to know a name is constructible.

is_package bool

Whether the file is an __init__.py. A relative import inside a package is rooted at the package itself, not at its parent, so this decides what from .x import resolves to.

is_entry bool

Whether this is the entrypoint the build starts from.

Source code in tempestweb/transpile/graph.py
@dataclass(frozen=True)
class LocalModule:
    """One project module reachable from the entrypoint.

    Attributes:
        name: The module's dotted name relative to the project root
            (``"api.tasks.service"``). The entrypoint's own name is included.
        path: The absolute path to the module's source file.
        source: The module's source text.
        tree: The parsed module.
        classes: The names of the classes the module declares at top level,
            needed by an importing module to know a name is constructible.
        is_package: Whether the file is an ``__init__.py``. A relative import
            inside a package is rooted at the package itself, not at its
            parent, so this decides what ``from .x import`` resolves to.
        is_entry: Whether this is the entrypoint the build starts from.
    """

    name: str
    path: Path
    source: str
    tree: ast.Module
    classes: frozenset[str]
    is_package: bool
    is_entry: bool

local_module_specifier

local_module_specifier(module: str) -> str

Return the ES import specifier for a sibling project module.

Every emitted module lands in the same directory as the entrypoint, named by its dotted path, so one flat specifier works from any depth — no ../.. to get wrong.

Parameters:

Name Type Description Default
module str

The sibling's dotted module name.

required

Returns:

Type Description
str

The relative specifier (api.tasks → ./api.tasks.gen.js).

Source code in tempestweb/transpile/codegen.py
def local_module_specifier(module: str) -> str:
    """Return the ES import specifier for a sibling project module.

    Every emitted module lands in the same directory as the entrypoint, named
    by its dotted path, so one flat specifier works from any depth — no
    ``../..`` to get wrong.

    Args:
        module: The sibling's dotted module name.

    Returns:
        The relative specifier (``api.tasks`` → ``./api.tasks.gen.js``).
    """
    return f"./{module}.gen.js"

resolve_graph

resolve_graph(entry: Path, root: Path) -> list[LocalModule]

Walk the entrypoint's local imports into a dependency-ordered graph.

Depth-first, so a module always appears after everything it imports — the order the emitter and the build write them in.

Parameters:

Name Type Description Default
entry Path

The absolute path to the entrypoint module.

required
root Path

The project root local module names resolve against.

required

Returns:

Type Description
list[LocalModule]

Every project module reachable from the entrypoint, dependencies first

list[LocalModule]

and the entrypoint last. A single-file app returns a one-item list.

Raises:

Type Description
TranspileError

If a local import cycles, or a relative import climbs above the project root. A cycle is refused rather than emitted: the ES modules would load, but a name read during evaluation of the other half throws on a temporal dead zone, at a line neither module names.

Source code in tempestweb/transpile/graph.py
def resolve_graph(entry: Path, root: Path) -> list[LocalModule]:
    """Walk the entrypoint's local imports into a dependency-ordered graph.

    Depth-first, so a module always appears after everything it imports — the
    order the emitter and the build write them in.

    Args:
        entry: The absolute path to the entrypoint module.
        root: The project root local module names resolve against.

    Returns:
        Every project module reachable from the entrypoint, dependencies first
        and the entrypoint last. A single-file app returns a one-item list.

    Raises:
        TranspileError: If a local import cycles, or a relative import climbs
            above the project root. A cycle is refused rather than emitted: the
            ES modules would load, but a name read during evaluation of the
            other half throws on a temporal dead zone, at a line neither module
            names.
    """
    entry = entry.resolve()
    root = root.resolve()
    ordered: list[LocalModule] = []
    done: set[str] = set()
    stack: list[str] = []

    def walk(path: Path, is_entry: bool) -> None:
        """Visit one module, its dependencies first.

        Args:
            path: The module file to visit.
            is_entry: Whether this is the entrypoint.
        """
        module = _read(path, root)
        if module.name in done:
            return
        if module.name in stack:
            cycle = " → ".join([*stack[stack.index(module.name) :], module.name])
            raise TranspileError(
                f"local imports form a cycle ({cycle}); Mode C refuses it "
                "because the emitted modules would throw on a temporal dead "
                "zone at a line neither of them names",
                None,
                path.name,
            )
        stack.append(module.name)
        for node in module.tree.body:
            if not isinstance(node, ast.ImportFrom):
                continue
            target = absolute_target(node, module.name, is_package=module.is_package)
            if not target:
                continue
            resolved = _resolve_name(target, root)
            if resolved is None:
                continue
            walk(resolved, False)
        stack.pop()
        done.add(module.name)
        ordered.append(
            LocalModule(
                name=module.name,
                path=module.path,
                source=module.source,
                tree=module.tree,
                classes=module.classes,
                is_package=module.is_package,
                is_entry=is_entry,
            )
        )

    walk(entry, True)
    return _propagate_reexports(ordered)

transpile_source

transpile_source(source: str, filename: str = '<source>', *, banner: str | None = None) -> str

Transpile Python module source into native-JS module source.

Parameters:

Name Type Description Default
source str

The Python source to transpile.

required
filename str

Source name for diagnostics and the default banner.

'<source>'
banner str | None

Optional leading comment line (see :func:generate).

None

Returns:

Type Description
str

The generated JavaScript module source.

Raises:

Type Description
TranspileError

If the module uses a construct outside the subset.

Source code in tempestweb/transpile/__init__.py
def transpile_source(
    source: str, filename: str = "<source>", *, banner: str | None = None
) -> str:
    """Transpile Python module source into native-JS module source.

    Args:
        source: The Python source to transpile.
        filename: Source name for diagnostics and the default banner.
        banner: Optional leading comment line (see :func:`generate`).

    Returns:
        The generated JavaScript module source.

    Raises:
        TranspileError: If the module uses a construct outside the subset.
    """
    return generate(source, filename, banner=banner)

transpile_file

transpile_file(path: str | Path, *, banner: str | None = None) -> str

Transpile a Python source file into native-JS module source.

Parameters:

Name Type Description Default
path str | Path

Path to the .py module to transpile.

required
banner str | None

Optional leading comment line (see :func:generate).

None

Returns:

Type Description
str

The generated JavaScript module source.

Raises:

Type Description
TranspileError

If the module uses a construct outside the subset.

Source code in tempestweb/transpile/__init__.py
def transpile_file(path: str | Path, *, banner: str | None = None) -> str:
    """Transpile a Python source file into native-JS module source.

    Args:
        path: Path to the `.py` module to transpile.
        banner: Optional leading comment line (see :func:`generate`).

    Returns:
        The generated JavaScript module source.

    Raises:
        TranspileError: If the module uses a construct outside the subset.
    """
    file_path = Path(path)
    source = file_path.read_text(encoding="utf-8")
    return generate(source, file_path.name, banner=banner)

transpile_project

transpile_project(entry: str | Path, root: str | Path, *, entry_filename: str = 'app.gen.js', banner: str | None = None, reserved: frozenset[str] = frozenset()) -> dict[str, str]

Transpile an entrypoint and every project module it imports.

A Mode C app is no longer one file: each local module the entrypoint reaches becomes its own ES module next to it, and the emitted imports wire them together. That is what lets an app import the typed client tempestweb gen api writes, which is a package.

Parameters:

Name Type Description Default
entry str | Path

Path to the entrypoint .py module.

required
root str | Path

The project root that local module names resolve against.

required
entry_filename str

The artifact name for the entrypoint's own module. The other modules are named after their dotted path.

'app.gen.js'
banner str | None

Optional leading comment line for the entrypoint (see :func:generate). Siblings always get the default banner.

None
reserved frozenset[str]

Artifact file names the emitted modules must not take — the client assets shipped into the same directory. A project module named widgets would otherwise write widgets.gen.js over the renderer's own, and the app would load the wrong file with no error anywhere.

frozenset()

Returns:

Type Description
dict[str, str]

Artifact file name → generated JavaScript source, the entrypoint

dict[str, str]

included. A single-file app returns exactly one entry.

Raises:

Type Description
TranspileError

If any module falls outside the subset, the local imports cycle, a relative import climbs above the root, or a module name collides with a reserved artifact.

Source code in tempestweb/transpile/__init__.py
def transpile_project(
    entry: str | Path,
    root: str | Path,
    *,
    entry_filename: str = "app.gen.js",
    banner: str | None = None,
    reserved: frozenset[str] = frozenset(),
) -> dict[str, str]:
    """Transpile an entrypoint and every project module it imports.

    A Mode C app is no longer one file: each local module the entrypoint reaches
    becomes its own ES module next to it, and the emitted imports wire them
    together. That is what lets an app import the typed client
    ``tempestweb gen api`` writes, which is a package.

    Args:
        entry: Path to the entrypoint `.py` module.
        root: The project root that local module names resolve against.
        entry_filename: The artifact name for the entrypoint's own module. The
            other modules are named after their dotted path.
        banner: Optional leading comment line for the entrypoint (see
            :func:`generate`). Siblings always get the default banner.
        reserved: Artifact file names the emitted modules must not take —
            the client assets shipped into the same directory. A project module
            named ``widgets`` would otherwise write ``widgets.gen.js`` over the
            renderer's own, and the app would load the wrong file with no error
            anywhere.

    Returns:
        Artifact file name → generated JavaScript source, the entrypoint
        included. A single-file app returns exactly one entry.

    Raises:
        TranspileError: If any module falls outside the subset, the local
            imports cycle, a relative import climbs above the root, or a module
            name collides with a reserved artifact.
    """
    modules = resolve_graph(Path(entry), Path(root))
    exported: Mapping[str, frozenset[str]] = {
        module.name: module.classes for module in modules
    }
    taken = reserved | {entry_filename}
    output: dict[str, str] = {}
    for module in modules:
        name = (
            entry_filename
            if module.is_entry
            else local_module_specifier(module.name).removeprefix("./")
        )
        if not module.is_entry and name in taken:
            raise TranspileError(
                f"the module `{module.name}` would be emitted as `{name}`, "
                "which the Mode C client already ships — rename it",
                None,
                module.path.name,
            )
        output[name] = generate(
            module.source,
            module.path.name,
            banner=banner if module.is_entry else None,
            module_name=module.name,
            module_is_package=module.is_package,
            local_modules=exported,
        )
    return output