Source code for exlab_wizard.template.copier_driver

"""Copier template-engine wrapper. Backend Spec §4.4.2 / §5.

This module wraps Copier's ``run_copy`` Python API behind the
:class:`TemplateEngine` contract documented in Backend Spec §4.4.2 so
that the rest of the app does not depend on Copier-specific types.

Two operations:

* :meth:`TemplateEngine.resolve` -- read and validate a template's
  ``copier.yml``. Returns a :class:`ResolvedTemplate`. Raises
  :class:`~exlab_wizard.errors.TemplateLoadError` on missing /
  malformed manifests, missing ``_exlab_version``, type mismatch
  against the caller-supplied scope, or invalid ``_exlab_run_scope``.
  Raises :class:`~exlab_wizard.errors.TemplateCoreFieldRedeclaredError`
  when ``_exlab_readme.fields`` declares one of the backend-managed
  core fields (``label`` / ``operator`` / ``objective``) -- see §10.3.

* :meth:`TemplateEngine.render` -- render the template into ``dst``
  by calling Copier under :func:`asyncio.to_thread` (Copier is sync).
  Always passes ``unsafe=False`` per §5.5: any ``_tasks`` in the
  template are silently ignored. Returns a :class:`RenderResult`
  carrying ``dst`` and the list of files Copier created (computed by
  walking ``dst`` before and after the call).

YAML reads use ``yaml.safe_load`` (Backend §4.3 docstring: PyYAML is
reserved for read-only YAML files like ``copier.yml`` where
round-trip preservation is not required).
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import copier
import yaml

from exlab_wizard.constants import COPIER_MANIFEST_NAME, RunScope, TemplateType
from exlab_wizard.errors import TemplateCoreFieldRedeclaredError, TemplateLoadError
from exlab_wizard.io import load_yaml_manifest
from exlab_wizard.logging import get_logger

__all__ = [
    "CORE_README_FIELD_IDS",
    "RenderResult",
    "ResolvedTemplate",
    "TemplateEngine",
]

# The three core README field IDs that templates may NOT redeclare.
# Backend Spec §10.3.
CORE_README_FIELD_IDS: frozenset[str] = frozenset({"label", "operator", "objective"})

_log = get_logger(__name__)


[docs] @dataclass(frozen=True) class ResolvedTemplate: """A loaded template's metadata. Backend Spec §5.2. Attributes: name: Template directory name (the ``Path.name`` of the template root). Used for display in the wizard. path: Absolute path to the template root (the directory containing ``copier.yml``). exlab_type: One of ``"project"``, ``"equipment"``, or ``"run"``. Mirrors ``_exlab_type`` from ``copier.yml``. exlab_version: Required non-empty string per §5.7. run_scope: One of ``"experimental"``, ``"test"``, ``"both"``; populated only for run templates (None otherwise). description: Free-form description from ``_exlab_description``; defaults to empty string. plugin_order: Plugin slug list from ``_exlab_plugins``. extra_readme_fields: Field-extension list declared under ``_exlab_readme.fields`` (free-form per §10.3). raw_manifest: The raw, fully-parsed ``copier.yml`` body. Useful for callers that need access to question definitions that this dataclass does not normalize. """ name: str path: Path exlab_type: TemplateType exlab_version: str run_scope: RunScope | None = None description: str = "" plugin_order: list[str] = field(default_factory=list) extra_readme_fields: list[dict[str, Any]] = field(default_factory=list) raw_manifest: dict[str, Any] = field(default_factory=dict)
[docs] @dataclass(frozen=True) class RenderResult: """Outcome of a render call. Attributes: dst_path: The absolute destination path Copier wrote into. files_written: Files Copier created during the render, computed by snapshotting ``dst`` before and after the call (so we capture the exact set Copier produced). """ dst_path: Path files_written: list[Path] = field(default_factory=list)
[docs] class TemplateEngine: """Wraps the Copier Python API behind the §4.4.2 contract."""
[docs] def resolve(self, template_path: Path, scope: TemplateType) -> ResolvedTemplate: """Load + validate a template's ``copier.yml``. Args: template_path: Path to the template directory (containing ``copier.yml``). scope: The caller-asserted template scope. The loaded ``_exlab_type`` must match. Returns: A :class:`ResolvedTemplate` describing the manifest. Raises: TemplateLoadError: ``copier.yml`` is missing, unreadable, or malformed; ``_exlab_version`` is missing or empty; ``_exlab_type`` is missing, invalid, or does not match ``scope``; or scope is ``run`` and ``_exlab_run_scope`` is missing or not one of ``{"experimental", "test", "both"}``. TemplateCoreFieldRedeclaredError: ``_exlab_readme.fields`` declares any of ``label`` / ``operator`` / ``objective``. """ manifest_path = template_path / COPIER_MANIFEST_NAME if not manifest_path.is_file(): raise TemplateLoadError( f"copier.yml not found at {manifest_path}", ) try: manifest = load_yaml_manifest(manifest_path) except OSError as exc: raise TemplateLoadError( f"failed to read {manifest_path}: {exc}", ) from exc except yaml.YAMLError as exc: raise TemplateLoadError( f"failed to parse {manifest_path}: {exc}", ) from exc # _exlab_type: must be present, valid, and match the caller scope. raw_type = manifest.get("_exlab_type") if not isinstance(raw_type, str) or not raw_type: raise TemplateLoadError( f"{manifest_path}: _exlab_type missing or empty", ) try: parsed_type = TemplateType(raw_type) except ValueError as exc: raise TemplateLoadError( f"{manifest_path}: _exlab_type must be one of " f"{sorted(t.value for t in TemplateType)}, got {raw_type!r}", ) from exc if parsed_type is not scope: raise TemplateLoadError( f"{manifest_path}: _exlab_type {parsed_type.value!r} does not " f"match requested scope {scope.value!r}", ) # _exlab_version: required non-empty string per §5.7. exlab_version = manifest.get("_exlab_version") if not isinstance(exlab_version, str) or not exlab_version.strip(): raise TemplateLoadError( f"{manifest_path}: _exlab_version is required and must be a " f"non-empty string (§5.7)", ) # _exlab_run_scope: required for run templates, optional otherwise. run_scope: RunScope | None = None if parsed_type is TemplateType.RUN: raw_scope = manifest.get("_exlab_run_scope") if not isinstance(raw_scope, str) or not raw_scope: raise TemplateLoadError( f"{manifest_path}: _exlab_run_scope is required for run " f"templates and must be one of " f"{sorted(s.value for s in RunScope)}", ) try: run_scope = RunScope(raw_scope) except ValueError as exc: raise TemplateLoadError( f"{manifest_path}: _exlab_run_scope must be one of " f"{sorted(s.value for s in RunScope)}, got {raw_scope!r}", ) from exc # _exlab_readme.fields: reject redeclaration of core fields. extra_fields = self._extract_readme_fields(manifest, manifest_path) # _tasks: silently ignored per §5.5; warn so authors know. if "_tasks" in manifest: _log.warning( "template %s declares _tasks; silently ignored " "(unsafe=False, see Backend Spec §5.5)", template_path, ) # _exlab_plugins: optional ordered list (§6.2.3). raw_plugins = manifest.get("_exlab_plugins") plugin_order = list(raw_plugins) if isinstance(raw_plugins, list) else [] raw_description = manifest.get("_exlab_description") description = raw_description if isinstance(raw_description, str) else "" return ResolvedTemplate( name=template_path.name, path=template_path, exlab_type=parsed_type, exlab_version=exlab_version, run_scope=run_scope, description=description, plugin_order=plugin_order, extra_readme_fields=extra_fields, raw_manifest=manifest, )
@staticmethod def _extract_readme_fields( manifest: dict[str, Any], manifest_path: Path ) -> list[dict[str, Any]]: """Pull ``_exlab_readme.fields`` and reject core-field collisions. Tolerates malformed shapes by returning an empty list when the ``_exlab_readme`` block or its ``fields`` key is not the expected shape. Raises :class:`TemplateCoreFieldRedeclaredError` only on the one fatal case: a field entry whose ``id`` is one of the backend-managed core fields (§10.3). """ readme_block = manifest.get("_exlab_readme") if not isinstance(readme_block, dict): return [] raw_fields = readme_block.get("fields") if not isinstance(raw_fields, list): return [] dict_entries = [entry for entry in raw_fields if isinstance(entry, dict)] for entry in dict_entries: field_id = entry.get("id") if isinstance(field_id, str) and field_id in CORE_README_FIELD_IDS: raise TemplateCoreFieldRedeclaredError( f"{manifest_path}: _exlab_readme.fields redeclares core " f"field {field_id!r}; core fields (label / operator / " f"objective) are backend-managed (§10.3)", ) return dict_entries
[docs] async def render( self, tpl: ResolvedTemplate, dst: Path, variables: dict[str, Any], ) -> RenderResult: """Render the template into ``dst`` via Copier. Args: tpl: A previously-resolved template. dst: Destination directory. Copier creates it if it does not exist; it will not silently overwrite existing files (``overwrite=False``). variables: The fully-resolved answer map. Bypasses Copier's interactive prompts (§5.3). Returns: A :class:`RenderResult` naming ``dst`` and listing the files Copier wrote. Raises: Whatever Copier raises -- e.g. ``copier.errors.UserMessageError`` when ``overwrite=False`` and a generated file already exists. The caller is expected to surface these via the §4.6.3 error envelope. """ before = _snapshot_files(dst) # §5.5: ExLab-Wizard never executes ``_tasks``. We pass both # ``unsafe=False`` (the spec invariant) and ``skip_tasks=True`` # so Copier silently skips any tasks it finds rather than # raising :class:`copier.errors.UnsafeTemplateError`. Together # these implement the spec's "silently ignored" contract on # current Copier (>=9.x). await asyncio.to_thread( copier.run_copy, src_path=str(tpl.path), dst_path=str(dst), data=variables, overwrite=False, unsafe=False, skip_tasks=True, quiet=True, ) after = _snapshot_files(dst) files_written = sorted(after - before) return RenderResult(dst_path=dst, files_written=files_written)
def _snapshot_files(root: Path) -> set[Path]: """Return the set of regular files under ``root`` (recursively). Returns the empty set if ``root`` does not exist. Paths are absolute so callers can compare snapshots taken at different times without worrying about ``cwd`` drift. """ if not root.exists(): return set() if not root.is_dir(): # A file at the dst path; treat as a single-entry snapshot. return {root.resolve()} return {p.resolve() for p in root.rglob("*") if p.is_file()}