Source code for codegen.assembler

"""Assembler: combine fragments into output files.

Each dispatched render gets a :class:`~codegen.render.RenderCtx`
with ``store`` and ``instance_id`` set to the current entry.
Renderers yield a :class:`~codegen.render.FileFragment` (declaring
the output file and its wrapper template) plus one or more
:class:`~codegen.render.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 dataclasses import replace
from functools import reduce
from itertools import groupby
from operator import attrgetter, or_
from typing import TYPE_CHECKING, Any

from codegen.banner import render_banner
from codegen.env import render_template
from codegen.imports import format_imports
from codegen.render import FileFragment, SnippetFragment
from codegen.spec import GeneratedFile

if TYPE_CHECKING:
    from codegen.render import Fragment, RenderCtx, RenderRegistry
    from codegen.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:`~codegen.spec.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="", if_exists=file.if_exists, executable=file.executable, ) 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, ) content = _with_banner( rendered.rstrip() + "\n", file=file, ctx=ctx, ) return GeneratedFile( path=file.path, content=content, if_exists=file.if_exists, executable=file.executable, ) def _with_banner(content: str, *, file: FileFragment, ctx: RenderCtx) -> str: """Prepend the autogenerated banner to *content* when applicable. No-ops when the file opts out (``banner=False``) or its type has no registered comment style. A leading line that must stay first -- a ``#!`` shebang or a Dockerfile ``# syntax=`` parser directive -- is kept first; the banner is inserted just below it. """ if not file.banner: return content banner = render_banner( target_name=ctx.target_name, path=file.path, if_exists=file.if_exists, ) if not banner: return content first_line = content.partition("\n")[0] if first_line.startswith(("#!", "# syntax=")): directive, _, rest = content.partition("\n") return f"{directive}\n{banner}\n\n{rest}" return f"{banner}\n\n{content}"