Source code for foundry.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 __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from foundry.env import render_template
from foundry.imports import ImportCollector
from foundry.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 ``foundry.import_formatters``
entry-point group.
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 = ""
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.
"""
path: str
template: str
context: dict[str, Any] = field(default_factory=dict)
imports: ImportCollector = field(default_factory=ImportCollector)
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 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)
return FileFragment(
path=self.path,
template=self.template,
context=self.context | other.context,
imports=self.imports | other.imports,
)
[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 foundry discovers operations via the
#: ``foundry.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()