"""Typed, round-trippable model of a Copier ``copier.yml`` manifest.
This module owns the two primitives the GUI authoring form and the
wizard consume questions through:
* :class:`TemplateQuestion` -- one Copier question normalised to the
widget family the wizard renders (``str`` / ``int`` / ``float`` /
``bool`` / ``choice``).
* :func:`template_questions` -- parse the operator-answerable questions
out of a raw ``copier.yml`` body (both Copier long- and short-form).
These two used to live in :mod:`exlab_wizard.ui.pages.templates`. They
were moved here so the (non-UI) :class:`TemplateManifest` model can
reuse them without importing a NiceGUI page module (an import cycle).
``ui.pages.templates`` re-exports them so existing callers keep working.
:class:`TemplateManifest` mirrors the ``_exlab_*`` metadata keys plus
the parsed questions and round-trips ``copier.yml`` so the structured
authoring form never hand-writes YAML:
``TemplateManifest.from_yaml(m.to_yaml()) == m`` holds for manifests
built from every supported question kind.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import yaml
__all__ = [
"TemplateManifest",
"TemplateQuestion",
"template_questions",
]
[docs]
@dataclass(frozen=True)
class TemplateQuestion:
"""One Copier question parsed from a template's ``copier.yml``.
``kind`` is normalised to the widget family the wizard renders:
``str`` / ``int`` / ``float`` / ``bool`` / ``choice``. ``choices``
is populated only for ``choice`` questions. ``secret`` flags a
password-style ``str`` input.
"""
key: str
kind: str
default: Any = None
choices: tuple[Any, ...] = ()
help: str = ""
secret: bool = False
# Copier reserves ``_``-prefixed manifest keys for itself; everything
# else under the top level is an operator-answerable question.
_COPIER_TYPE_TO_KIND: dict[str, str] = {
"str": "str",
"int": "int",
"float": "float",
"bool": "bool",
"yaml": "str",
"json": "str",
}
# Reverse map for emitting a Copier ``type`` string from a normalised
# :class:`TemplateQuestion.kind`. ``choice`` is special-cased (emits
# ``type: str`` + a ``choices`` block) in :meth:`TemplateManifest.to_yaml`.
_KIND_TO_COPIER_TYPE: dict[str, str] = {
"str": "str",
"int": "int",
"float": "float",
"bool": "bool",
"choice": "str",
}
[docs]
def template_questions(raw_manifest: dict[str, Any]) -> list[TemplateQuestion]:
"""Parse the operator-answerable questions out of a ``copier.yml`` body.
Handles both Copier question forms:
* **long form** -- ``key: {type: ..., default: ..., choices: ...}``
* **short form** -- ``key: <scalar>`` (the scalar is the default;
the type is inferred from it)
``_``-prefixed keys (Copier / ``_exlab_*`` metadata) are skipped.
Questions carrying a ``when`` clause are still returned -- the
wizard renders them unconditionally for v1.
"""
questions: list[TemplateQuestion] = []
for key, spec in raw_manifest.items():
if key.startswith("_"):
continue
if isinstance(spec, dict):
raw_type = str(spec.get("type", "str"))
raw_choices = spec.get("choices")
choices: tuple[Any, ...] = ()
if isinstance(raw_choices, dict):
choices = tuple(raw_choices.values())
elif isinstance(raw_choices, list):
choices = tuple(raw_choices)
kind = "choice" if choices else _COPIER_TYPE_TO_KIND.get(raw_type, "str")
questions.append(
TemplateQuestion(
key=key,
kind=kind,
default=spec.get("default"),
choices=choices,
help=str(spec.get("help", "")),
secret=bool(spec.get("secret", False)),
)
)
else:
# Short form: the scalar is the default; infer the kind.
if isinstance(spec, bool):
kind = "bool"
elif isinstance(spec, int):
kind = "int"
elif isinstance(spec, float):
kind = "float"
else:
kind = "str"
questions.append(TemplateQuestion(key=key, kind=kind, default=spec))
return questions
[docs]
@dataclass(frozen=True)
class TemplateManifest:
"""A typed, round-trippable model of a Copier ``copier.yml``.
Mirrors the ``_exlab_*`` metadata keys plus the parsed
:class:`TemplateQuestion` list. Built so the structured authoring
form never hand-writes YAML: :meth:`from_yaml` parses a manifest
(string or already-parsed dict) and :meth:`to_yaml` emits a
deterministic, long-form ``copier.yml`` such that
``TemplateManifest.from_yaml(m.to_yaml()) == m``.
Attributes:
exlab_type: One of ``"project"`` / ``"equipment"`` / ``"run"``
(``_exlab_type``). Not validated here -- see
:mod:`exlab_wizard.template.lint`.
exlab_version: The required ``_exlab_version`` string (§5.7).
exlab_run_scope: ``_exlab_run_scope`` for run templates; ``None``
otherwise (and then never emitted by :meth:`to_yaml`).
description: ``_exlab_description`` free-form text.
plugins: Ordered ``_exlab_plugins`` slug list (§6.2.3); emitted
only when non-empty.
readme_fields: ``_exlab_readme.fields`` field-extension list
(§10.3), each a free-form dict.
questions: Parsed operator-answerable questions, emitted in
long form.
min_copier_version: ``_min_copier_version`` (defaults ``"9.0"``).
answers_file: ``_answers_file`` (defaults ``".exlab-answers.yml"``).
"""
exlab_type: str
exlab_version: str
exlab_run_scope: str | None = None
description: str = ""
plugins: list[str] = field(default_factory=list)
readme_fields: list[dict[str, Any]] = field(default_factory=list)
questions: list[TemplateQuestion] = field(default_factory=list)
min_copier_version: str = "9.0"
answers_file: str = ".exlab-answers.yml"
[docs]
@classmethod
def from_yaml(cls, raw: str | dict[str, Any]) -> TemplateManifest:
"""Build a :class:`TemplateManifest` from a manifest body.
Args:
raw: Either a raw ``copier.yml`` string (parsed with
:func:`yaml.safe_load`) or an already-parsed mapping.
Returns:
A :class:`TemplateManifest`. Missing ``_exlab_*`` / ``_*``
keys fall back to the dataclass defaults; questions are
parsed via :func:`template_questions`.
"""
data: dict[str, Any]
if isinstance(raw, str):
parsed = yaml.safe_load(raw)
data = parsed if isinstance(parsed, dict) else {}
else:
data = raw
raw_type = data.get("_exlab_type")
exlab_type = raw_type if isinstance(raw_type, str) else ""
raw_version = data.get("_exlab_version")
exlab_version = raw_version if isinstance(raw_version, str) else ""
raw_scope = data.get("_exlab_run_scope")
exlab_run_scope = raw_scope if isinstance(raw_scope, str) else None
raw_description = data.get("_exlab_description")
description = raw_description if isinstance(raw_description, str) else ""
raw_plugins = data.get("_exlab_plugins")
plugins = list(raw_plugins) if isinstance(raw_plugins, list) else []
readme_fields: list[dict[str, Any]] = []
readme_block = data.get("_exlab_readme")
if isinstance(readme_block, dict):
raw_fields = readme_block.get("fields")
if isinstance(raw_fields, list):
readme_fields = [e for e in raw_fields if isinstance(e, dict)]
raw_min = data.get("_min_copier_version")
min_copier_version = raw_min if isinstance(raw_min, str) else "9.0"
raw_answers = data.get("_answers_file")
answers_file = raw_answers if isinstance(raw_answers, str) else ".exlab-answers.yml"
return cls(
exlab_type=exlab_type,
exlab_version=exlab_version,
exlab_run_scope=exlab_run_scope,
description=description,
plugins=plugins,
readme_fields=readme_fields,
questions=template_questions(data),
min_copier_version=min_copier_version,
answers_file=answers_file,
)
[docs]
def to_yaml(self) -> str:
"""Emit a deterministic, long-form ``copier.yml`` string.
Keys are emitted in a fixed order (Copier metadata first, then
each question in long form) with ``sort_keys=False`` so the
ordering is preserved. ``_exlab_run_scope`` is emitted only when
not ``None`` and ``_exlab_plugins`` only when non-empty. Each
question emits ``{type, help, default, choices, secret}`` with
empty / ``None`` sub-keys omitted, so
:meth:`from_yaml` reconstructs the identical model.
"""
body: dict[str, Any] = {
"_min_copier_version": self.min_copier_version,
"_answers_file": self.answers_file,
"_exlab_type": self.exlab_type,
"_exlab_version": self.exlab_version,
}
if self.exlab_run_scope is not None:
body["_exlab_run_scope"] = self.exlab_run_scope
body["_exlab_description"] = self.description
body["_exlab_readme"] = {"fields": self.readme_fields}
if self.plugins:
body["_exlab_plugins"] = list(self.plugins)
for question in self.questions:
body[question.key] = _question_to_long_form(question)
return yaml.safe_dump(body, sort_keys=False)
def _question_to_long_form(question: TemplateQuestion) -> dict[str, Any]:
"""Render one :class:`TemplateQuestion` as a Copier long-form spec.
Maps ``kind`` back to a Copier ``type`` string; ``choice`` questions
emit ``type: str`` plus a ``choices`` list. Empty / ``None`` sub-keys
(``help``, ``default``, ``choices``, ``secret``) are omitted so the
round-trip through :func:`template_questions` is lossless.
"""
spec: dict[str, Any] = {"type": _KIND_TO_COPIER_TYPE.get(question.kind, "str")}
if question.help:
spec["help"] = question.help
if question.default is not None:
spec["default"] = question.default
if question.choices:
spec["choices"] = list(question.choices)
if question.secret:
spec["secret"] = True
return spec