Source code for codegen.render

"""Render registry for output types.

The ``@renders`` decorator registers a function that knows how
to turn a build output into a :class:`Fragment` -- a path,
import set, and shell-template spec.  The engine/assembler
calls renderers after the build phase and then groups fragments
by output path to produce final files.
"""

from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal

from codegen.env import render_template
from codegen.imports import ImportCollector
from codegen.store import BuildStore

if TYPE_CHECKING:
    import jinja2


[docs] @dataclass(frozen=True) class RenderCtx: """Context passed to every renderer function. Attributes: env: Jinja2 environment for template lookups. config: The full project config dict (or model). package_prefix: Dotted prefix for generated imports, e.g. ``"_generated"``. language: Target language identifier used to render import blocks (e.g. ``"python"``). Must match a formatter declared in the ``codegen.import_formatters`` entry-point group. target_name: The target's short name (e.g. ``"be"``), used by the engine-emitted file banner to name the ``codegen generate --target <name>`` command that produced the file. store: The build store. Renderers reach ancestor scope instances through it (e.g. a handler rendered at operation scope looks up its resource via ``store.ancestor_of(instance_id, "resource")``). instance_id: Id of the scope instance whose output is being rendered. Paired with :attr:`store` for ancestor and self lookups. """ env: jinja2.Environment config: Any package_prefix: str = "" language: str = "" target_name: str = "" store: BuildStore = field(default_factory=BuildStore) instance_id: str = ""
[docs] @dataclass class FileFragment: """Declares an output file's wrapper template and scalar context. One :class:`FileFragment` per output path describes the template the assembler wraps the file in and the non-slot context passed to it. Every :class:`SnippetFragment` sharing that path contributes a slot-list item that the assembler folds into :attr:`context` before the wrapper is rendered. Multiple renderers may emit a :class:`FileFragment` for the same path (e.g. every route handler at the resource declares the route file) — the assembler requires them to agree on :attr:`template` and unifies their :attr:`context` dicts, raising if two disagree on a shared key. A blank :attr:`template` is a convention for an empty-content file (e.g. ``__init__.py``). Attributes: path: Output path relative to the output directory. template: Jinja2 template name that wraps the file. context: Non-slot template variables. Merged across all FileFragments at this path (shared keys must agree). imports: Imports the wrapper itself needs, on top of any contributed by snippets. if_exists: Write policy for the assembled output. Set to ``"skip"`` to make :func:`codegen.output.write_files` leave existing files alone (be_root's re-bootstrap-safe path); defaults to ``"overwrite"``. When two fragments at the same path disagree, the assembler picks the stricter ``"overwrite"`` -- a single contributor that wants the file regenerated wins over any contributor that would have been happy to skip. executable: When ``True``, :func:`codegen.output.write_files` chmods the emitted file ``+x`` (user/group/other). Use for shell scripts that the rendered ``justfile`` / ``package.json`` invokes via a bare ``./scripts/foo.sh``. Merge: ``True`` wins -- any contributor that wants the bit set wins over a peer that doesn't care. banner: When ``True`` (default), the engine prepends the autogenerated header banner (see :mod:`codegen.banner`) to the rendered content. Set ``False`` to suppress it for files that must not carry one. Merge: ``False`` wins -- any contributor that opts out suppresses it. """ path: str template: str context: dict[str, Any] = field(default_factory=dict) imports: ImportCollector = field(default_factory=ImportCollector) if_exists: Literal["overwrite", "skip"] = "overwrite" executable: bool = False banner: bool = True def __or__(self, other: FileFragment) -> FileFragment: """Merge two FileFragments targeting the same path. Raises :class:`ValueError` if the two fragments disagree on :attr:`template`, or if any shared :attr:`context` key has two different values. Imports union. ``if_exists`` resolves to ``"overwrite"`` when either side says so -- any contributor that wants the file regenerated wins. ``executable`` resolves the same way: ``True`` if either side wants the bit set. """ if self.template != other.template: msg = ( f"FileFragment template mismatch at {self.path!r}: " f"{self.template!r} vs {other.template!r}" ) raise ValueError(msg) for key in self.context.keys() & other.context.keys(): if self.context[key] != other.context[key]: msg = ( f"FileFragment context conflict at {self.path!r} " f"for {key!r}: {self.context[key]!r} vs " f"{other.context[key]!r}" ) raise ValueError(msg) merged_if_exists: Literal["overwrite", "skip"] = ( "overwrite" if "overwrite" in (self.if_exists, other.if_exists) else "skip" ) return FileFragment( path=self.path, template=self.template, context=self.context | other.context, imports=self.imports | other.imports, if_exists=merged_if_exists, executable=self.executable or other.executable, banner=self.banner and other.banner, )
[docs] @dataclass class SnippetFragment: """A contribution slotted into a file's context list. Each snippet becomes one entry in ``file.context[slot]`` — a list the wrapper template iterates over. Snippets at the same path may target different slots. Supply exactly one of :attr:`template` (rendered by the assembler into a string) or :attr:`value` (used as-is, may be any type — useful for dict slots the wrapper iterates over itself). Attributes: path: Output path; must match a :class:`FileFragment`. slot: Key in the file's context this snippet appends to. template: Jinja2 template the assembler renders against :attr:`context` to produce a string slot item. Mutually exclusive with :attr:`value`. context: Template variables for :attr:`template`. value: Raw slot item — any type, used as-is. Mutually exclusive with :attr:`template`. imports: Imports this contribution needs in the output file's import block. """ path: str slot: str template: str | None = None context: dict[str, Any] = field(default_factory=dict) value: Any = None imports: ImportCollector = field(default_factory=ImportCollector)
[docs] def render_slot_item(self, env: jinja2.Environment) -> object: """Return the slot-list item this snippet contributes. When :attr:`template` is set the assembler renders it against :attr:`context` and strips surrounding whitespace, so the surrounding file template can join items with its own separators without fighting jinja's trailing newline. Otherwise :attr:`value` is passed through unchanged. """ if self.template is not None: return render_template( env=env, template_name=self.template, **self.context, ).strip() return self.value
#: Union of fragment types a renderer may yield. Fragment = FileFragment | SnippetFragment _RendererFn = Callable[[Any, RenderCtx], "Iterable[Fragment]"]
[docs] @dataclass class RenderRegistry: """Maps output types to renderer functions. Example:: registry = RenderRegistry() @registry.renders(RouteHandler) def render_route(handler, ctx): return Fragment(...) """ _entries: dict[type, _RendererFn] = field(default_factory=dict)
[docs] def renders( self, output_type: type, ) -> Callable[[_RendererFn], _RendererFn]: """Register a renderer for *output_type*. Args: output_type: The output class this renderer handles. Returns: The original function, unmodified. """ def decorator(fn: _RendererFn) -> _RendererFn: self._entries[output_type] = fn return fn return decorator
[docs] def render( self, obj: object, ctx: RenderCtx, ) -> list[Fragment]: """Produce fragments for a build output. Every registered renderer returns an iterable of fragments (typically as a generator via ``yield``). Renderers usually yield a :class:`FileFragment` declaring the output file plus one or more :class:`SnippetFragment` contributions into its slots. Args: obj: The build output to render. ctx: Render context. Returns: A list of fragments. May be empty if the renderer decides not to contribute. Raises: LookupError: No renderer registered for the type. """ output_type = type(obj) fn = self._entries.get(output_type) if fn is None: msg = f"No renderer for {output_type.__name__}" raise LookupError(msg) return list(fn(obj, ctx))
#: Process-wide render registry. #: #: Targets' renderer modules register into this singleton at #: import time. Because codegen discovers operations via the #: ``codegen.operations`` entry-point group and loading an #: operation transitively imports its renderer module, no #: separate renderer-discovery step is needed — by the time the #: pipeline's assembler runs, every renderer is registered. registry = RenderRegistry()