Source code for foundry.assembler

"""Assembler: combine fragments into output files.

Each dispatched render gets a :class:`RenderCtx` with ``store``
and ``instance_id`` set to the current entry.  Renderers yield a
:class:`FileFragment` (declaring the output file and its
wrapper template) plus one or more :class:`SnippetFragment`
contributions into the file's slot lists.  This module folds
them: files with the same path merge via ``|``, snippets render
(either from ``value`` or their ``template``), and each file's
wrapper is rendered once with every slot's items in order.
"""

from __future__ import annotations

from dataclasses import replace
from functools import reduce
from itertools import groupby
from operator import attrgetter, or_
from typing import TYPE_CHECKING, Any

from foundry.env import render_template
from foundry.imports import format_imports
from foundry.render import FileFragment, SnippetFragment
from foundry.spec import GeneratedFile

if TYPE_CHECKING:
    from foundry.render import Fragment, RenderCtx, RenderRegistry
    from foundry.store import BuildStore


[docs] def assemble( store: BuildStore, registry: RenderRegistry, ctx: RenderCtx, ) -> list[GeneratedFile]: """Turn a build store into rendered output files. Walks every item in the store, dispatches to the registry to collect file/snippet fragments, then renders one file per declared shell with its snippets folded in. Args: store: The build store from the engine's build phase. registry: Render registry with all renderers registered. ctx: Render context -- env, config, package prefix. Returns: Flat list of :class:`GeneratedFile` objects ready for output. """ ctx = replace(ctx, store=store) fragments: list[Fragment] = [] for instance_id, _, items in store.entries(): dispatch_ctx = replace(ctx, instance_id=instance_id) fragments.extend( fragment for item in items for fragment in registry.render(obj=item, ctx=dispatch_ctx) ) return _assemble_files(fragments=fragments, ctx=ctx)
def _assemble_files( fragments: list[Fragment], ctx: RenderCtx, ) -> list[GeneratedFile]: """Partition by type, then render one file per declared path. FileFragments at the same path are merged via ``|``, which raises on template/context disagreement. Snippets whose path has no matching FileFragment also raise. """ files = [frag for frag in fragments if isinstance(frag, FileFragment)] snippets = [frag for frag in fragments if isinstance(frag, SnippetFragment)] files_by_path = _group_by_path(fragments=files) snippets_by_path = _group_by_path(fragments=snippets) orphan_paths = snippets_by_path.keys() - files_by_path.keys() if orphan_paths: msg = ( "SnippetFragment targets path with no FileFragment: " f"{sorted(orphan_paths)}" ) raise ValueError(msg) return [ _render_file( file=reduce(or_, group), snippets=snippets_by_path.get(path, ()), ctx=ctx, ) for path, group in files_by_path.items() ] def _group_by_path[T: (FileFragment, SnippetFragment)]( fragments: list[T], ) -> dict[str, list[T]]: """Bucket *fragments* by their ``path`` attribute.""" _path_of = attrgetter("path") ordered = sorted(fragments, key=_path_of) return {path: list(group) for path, group in groupby(ordered, key=_path_of)} def _render_file( file: FileFragment, snippets: list[SnippetFragment] | tuple[SnippetFragment, ...], ctx: RenderCtx, ) -> GeneratedFile: """Render *file* with *snippets* folded into its slot lists. A blank :attr:`FileFragment.template` produces empty content (convention for empty files like ``__init__.py``). """ if not file.template: return GeneratedFile(path=file.path, content="") imports = file.imports slots: dict[str, list[Any]] = {} for snippet in snippets: imports = imports | snippet.imports slots.setdefault(snippet.slot, []).append( snippet.render_slot_item(env=ctx.env) ) context: dict[str, Any] = { **file.context, **slots, "import_block": format_imports( collector=imports, language=ctx.language ), } rendered = render_template( env=ctx.env, template_name=file.template, **context, ) return GeneratedFile(path=file.path, content=rendered.rstrip() + "\n")