Source code for exlab_wizard.ui.pages.templates

"""Template manager page (Frontend Spec §4 step 2, §5 step 2).

Two operations the wizards depend on:

* :func:`list_templates` -- scan ``config.paths.templates_dir`` for
  Copier templates (directories containing a ``copier.yml``) and return
  a small summary per template. The project / run wizards call this to
  populate their "pick a template" step.
* :func:`create_template` -- scaffold a new minimal Copier template
  under ``templates_dir``: a ``copier.yml`` carrying the ``_exlab_*``
  manifest keys plus one rendered content file. The result is
  immediately loadable by :class:`~exlab_wizard.template.copier_driver.TemplateEngine`.

``list_templates`` / ``create_template`` are pure (no NiceGUI) so they
are unit-testable; :func:`render_template_manager` is the NiceGUI view.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import yaml

from exlab_wizard.constants import COPIER_MANIFEST_NAME, RunScope, TemplateType
from exlab_wizard.logging import get_logger

# ``TemplateQuestion`` / ``template_questions`` live in ``template.manifest``
# (a non-UI module) so the typed manifest model can reuse them without
# importing this NiceGUI page. Re-exported here so existing callers
# (``from exlab_wizard.ui.pages.templates import TemplateQuestion``) keep
# working unchanged.
from exlab_wizard.template.manifest import TemplateQuestion, template_questions

__all__ = [
    "TemplateQuestion",
    "TemplateSummary",
    "create_template",
    "list_templates",
    "render_question_field",
    "render_template_manager",
    "template_questions",
]

_log = get_logger(__name__)

# Content file every scaffolded template carries. ``.jinja`` so Copier
# renders it; the body has no variables so it renders verbatim.
_SCAFFOLD_CONTENT_NAME = "notes.md.jinja"
_SCAFFOLD_CONTENT_BODY = "# Notes\n\nScaffolded by ExLab-Wizard.\n"


[docs] @dataclass(frozen=True) class TemplateSummary: """One row in the template list. ``name`` is the template directory name (what the wizards store as ``selected_template``); ``path`` is its absolute location; ``template_type`` / ``run_scope`` / ``description`` come from the ``_exlab_*`` keys in ``copier.yml``. """ name: str path: Path template_type: str run_scope: str | None description: str
[docs] def list_templates( templates_dir: Path, *, template_type: str | None = None, ) -> list[TemplateSummary]: """Return the templates under ``templates_dir``, optionally filtered. A template is any immediate sub-directory containing a ``copier.yml``. Malformed manifests are skipped with a WARN rather than failing the whole scan. When ``template_type`` is given, only templates whose ``_exlab_type`` matches are returned. """ root = Path(templates_dir) if not root.is_dir(): return [] summaries: list[TemplateSummary] = [] for entry in sorted(root.iterdir()): manifest = entry / COPIER_MANIFEST_NAME if not entry.is_dir() or not manifest.is_file(): continue try: data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {} except Exception as exc: _log.warning("skipping malformed template manifest %s: %s", manifest, exc) continue if not isinstance(data, dict): continue t_type = str(data.get("_exlab_type", "")) if template_type is not None and t_type != template_type: continue summaries.append( TemplateSummary( name=entry.name, path=entry, template_type=t_type, run_scope=( str(data["_exlab_run_scope"]) if data.get("_exlab_run_scope") is not None else None ), description=str(data.get("_exlab_description", "")), ) ) return summaries
[docs] def create_template( templates_dir: Path, *, name: str, template_type: str, description: str = "", run_scope: str | None = None, ) -> Path: """Scaffold a new minimal Copier template under ``templates_dir``. Writes ``<templates_dir>/<name>/copier.yml`` plus one content file. Returns the new template's root directory. Raises ``ValueError`` on an empty / duplicate name, an unknown ``template_type``, or a run template missing its ``run_scope``. The scaffold logic lives in :func:`exlab_wizard.template.authoring.create_template_dir` (the non-UI authoring service); this thin wrapper preserves the historical page-level signature and is imported lazily to avoid an import cycle. """ # Imported lazily so this NiceGUI page module does not pull in the # authoring service (and its deps) at import time. from exlab_wizard.template.authoring import create_template_dir return create_template_dir( Path(templates_dir), name=name, template_type=template_type, description=description, run_scope=run_scope, )
[docs] def render_question_field( question: TemplateQuestion, answers: dict[str, Any], *, testid_prefix: str, ) -> None: """Render one Copier question as a bound NiceGUI widget. The widget two-way binds into ``answers[question.key]``; the entry is seeded with the question's default so a never-touched field still contributes its default to the render. ``testid_prefix`` namespaces the ``data-testid`` (``f"{prefix}-{key}"``). """ from nicegui import ui key = question.key answers.setdefault(key, question.default) testid = f"{testid_prefix}-{key}" label = key.replace("_", " ").strip().title() widget: Any if question.kind == "bool": widget = ui.checkbox(label, value=bool(answers.get(key))) widget.props(f'data-testid="{testid}"') widget.on_value_change(lambda e: answers.__setitem__(key, bool(e.value))) return if question.kind in ("int", "float"): current = answers.get(key) widget = ui.number(label=label, value=current if current is not None else 0) widget.props(f'data-testid="{testid}"') cast = int if question.kind == "int" else float widget.on_value_change( lambda e: answers.__setitem__(key, cast(e.value) if e.value is not None else None) ) return if question.kind == "choice": widget = ui.select( list(question.choices), value=answers.get(key) if answers.get(key) in question.choices else None, label=label, ) widget.props(f'data-testid="{testid}"') widget.on_value_change(lambda e: answers.__setitem__(key, e.value)) return # str (and yaml/json, which the wizard treats as free text). widget = ui.input(label=label, value=str(answers.get(key) or "")) widget.props(f'data-testid="{testid}"') if question.secret: widget.props("type=password") widget.on_value_change(lambda e: answers.__setitem__(key, e.value or ""))
[docs] def render_template_manager( *, templates: list[TemplateSummary], on_create: Callable[[str, str, str, str | None], None] | None = None, on_back: Callable[[], None] | None = None, on_edit: Callable[[str], None] | None = None, locations: list[tuple[str, str]] | None = None, on_location_change: Callable[[str], None] | None = None, ) -> Any: """Render the template manager: existing-template list + create form. ``on_create`` is invoked with ``(name, template_type, description, run_scope)`` when the operator submits the create form; ``run_scope`` is ``None`` for non-run templates. ``on_edit`` (optional) is invoked with a template name when the operator clicks that row's Edit button -- the caller routes it to the template editor page. ``locations`` (optional) is a ``[(label, value), ...]`` scope list (at minimum ``("Global", ...)`` plus one entry per configured equipment / project); when given, a selector is rendered at the top and ``on_location_change(value)`` is invoked when the operator switches scope (the caller re-renders the manager scoped to that location). All three new parameters are optional and default ``None`` so existing callers keep working unchanged. """ payload = { "templates": [t.name for t in templates], "count": len(templates), } if locations is not None: payload["locations"] = list(locations) try: from nicegui import ui except Exception: return payload card = ( ui.card() .props('data-testid="templates-card"') .style( "min-width: 720px; margin: 2rem auto; padding: var(--sp-6); " "background: var(--color-surface); border-radius: var(--radius-md);" ) ) with card: ui.label("Templates").props('data-testid="templates-title"').style( "font-family: var(--font-display); font-size: var(--text-lg); " "font-weight: 600; color: var(--color-heading);" ) # Scope / location selector ----------------------------------------- if locations: label_by_value = {value: label for label, value in locations} location_select = ui.select( {value: label for label, value in locations}, value=locations[0][1], label="Location", ).props('data-testid="templates-location"') if on_location_change is not None: location_select.on_value_change( lambda e: on_location_change(e.value) if e.value in label_by_value else None ) # Existing templates ------------------------------------------------ if templates: for summary in templates: scope = f" [{summary.run_scope}]" if summary.run_scope else "" with ( ui.row() .classes("items-center w-full") .props('data-testid="template-row"') .style("gap: var(--sp-2);") ): ui.label(f"{summary.name} -- {summary.template_type}{scope}").style( "color: var(--color-body); flex: 1;" ) if on_edit is not None: ui.button( "Edit", on_click=lambda _evt, n=summary.name: on_edit(n), ).props(f'flat dense data-testid="template-edit-{summary.name}"') else: ui.label("No templates yet. Create one below.").props( 'data-testid="templates-empty"' ).style("color: var(--color-muted);") # Create form ------------------------------------------------------- ui.label("New template").style("font-weight: 600; padding-top: var(--sp-3);") name_input = ui.input(label="Template name").props('data-testid="template-name"') type_select = ui.select( [t.value for t in TemplateType], value=TemplateType.PROJECT.value, label="Template type", ).props('data-testid="template-type"') scope_select = ui.select( [s.value for s in RunScope], value=RunScope.EXPERIMENTAL.value, label="Run scope (run templates only)", ).props('data-testid="template-run-scope"') description_input = ui.input(label="Description").props( 'data-testid="template-description"' ) def _submit(_evt: Any = None) -> None: if on_create is None: return run_scope = scope_select.value if type_select.value == TemplateType.RUN.value else None on_create( name_input.value or "", type_select.value or TemplateType.PROJECT.value, description_input.value or "", run_scope, ) with ( ui.row() .classes("items-center w-full justify-end") .style("gap: var(--sp-3); padding-top: var(--sp-4);") ): if on_back is not None: ui.button("Back", on_click=lambda _evt: on_back()).props( 'flat data-testid="templates-back"' ) ui.button("Create template", on_click=_submit).props( 'color=primary data-testid="template-create"' ) return card