"""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}"