Source code for exlab_wizard.template.resolution

"""Read-side template resolver (generalizes Backend Spec §5.0).

The project / run wizards do not offer a single flat template list: a
template defined nearer the work (per-project, then per-equipment) should
override a same-named template defined further out (the global
``paths.templates_dir``). This module turns a config plus a wizard context
(``template_type`` + optional ``equipment_id`` / ``project_path``) into the
ordered, de-duplicated list of templates the wizard should offer, nearest
scope first, nearest scope winning on a name collision.

Directory layout searched (design spec §2):

* **Global** -- ``paths.templates_dir`` (flat: templates sit directly
  inside, all types mixed, filtered by ``_exlab_type``).
* **Per-equipment** -- ``<local_root>/<equipment_id>/.exlab-wizard/templates/<type>/``
  (type-segregated).
* **Per-project** -- ``<project_path>/.exlab-wizard/templates/<type>/``
  (type-segregated).

The per-instance directory scanning + manifest parsing is delegated to
:func:`exlab_wizard.ui.pages.templates.list_templates`; this module only
composes the search chain and merges the results.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from exlab_wizard.constants import TEMPLATES_SUBDIR, RunScope, TemplateType
from exlab_wizard.paths import cache_dir

if TYPE_CHECKING:
    from pathlib import Path

    from exlab_wizard.config.models import Config
    from exlab_wizard.ui.pages.templates import TemplateQuestion, TemplateSummary

__all__ = [
    "TemplateChoices",
    "instance_template_dir",
    "project_dir",
    "reconcile_selection",
    "resolve_template_chain",
    "search_dirs",
]


[docs] @dataclass(frozen=True) class TemplateChoices: """The templates a wizard offers for one resolution context. Bundles the three parallel views the wizard's template + variables steps need so a single ``on_resolve(equipment_id, project_name)`` call returns everything for the current selection: Attributes: names: Offered template names, nearest scope first. questions: Per-template parsed ``copier.yml`` questions (drives the dynamic Variables step). Missing entry == no variables. paths: Per-template absolute source path of the *resolved* template, so ``on_submit`` renders the exact file the wizard listed rather than re-deriving ``templates_dir / name`` (which would ignore a per-instance override). Keyed by template name. """ names: list[str] = field(default_factory=list) questions: dict[str, list[TemplateQuestion]] = field(default_factory=dict) paths: dict[str, Path] = field(default_factory=dict)
[docs] def instance_template_dir(instance_dir: Path, template_type: str) -> Path: """Return the per-instance template directory for one scope. A per-instance (per-equipment / per-project) template store is ``<instance_dir>/.exlab-wizard/templates/<template_type>/`` -- the ``.exlab-wizard`` cache dir, a ``templates`` sub-dir, then a child sub-dir per template type so the project / run stores never collide. Args: instance_dir: The equipment or project root directory. template_type: The child template type (``"project"`` / ``"run"``) whose sub-dir is returned. Returns: The (possibly non-existent) per-instance template directory. """ return cache_dir(instance_dir) / TEMPLATES_SUBDIR / template_type
[docs] def search_dirs( config: Config, *, template_type: str, equipment_id: str | None = None, project_path: Path | None = None, ) -> list[Path]: """Return the template search directories, highest precedence first. The precedence chain narrows from the work outwards: a per-project store beats a per-equipment store, which beats the global ``paths.templates_dir``. Which scopes apply depends on the template type the wizard is offering: * ``"run"`` -- per-project, then per-equipment, then global. * ``"project"`` -- per-equipment, then global (a project has no children of its own to scope project templates by). * ``"equipment"`` -- global only. A scope is included only when its backing config value is present: the global dir is skipped when ``paths.templates_dir`` is empty, the per-equipment dir when ``equipment_id`` is ``None`` (or ``local_root`` is empty), and the per-project dir when ``project_path`` is ``None``. Returned directories need not exist -- :func:`list_templates` tolerates a missing directory by returning no templates. Args: config: The loaded config (supplies ``paths.templates_dir`` and ``paths.local_root``). template_type: One of ``"project"`` / ``"run"`` / ``"equipment"``. equipment_id: The equipment the wizard runs under, if any. Gates the per-equipment scope. project_path: The absolute project directory, if any. Gates the per-project scope (run wizard only). Returns: The ordered list of search directories, nearest scope first. """ dirs: list[Path] = [] if template_type == TemplateType.RUN.value: if project_path is not None: dirs.append(instance_template_dir(project_path, template_type)) equipment_dir = _equipment_dir(config, equipment_id) if equipment_dir is not None: dirs.append(instance_template_dir(equipment_dir, template_type)) elif template_type == TemplateType.PROJECT.value: equipment_dir = _equipment_dir(config, equipment_id) if equipment_dir is not None: dirs.append(instance_template_dir(equipment_dir, template_type)) global_dir = _global_dir(config) if global_dir is not None: dirs.append(global_dir) return dirs
[docs] def resolve_template_chain( config: Config, *, template_type: str, equipment_id: str | None = None, project_path: Path | None = None, run_scope: str | None = None, ) -> list[TemplateSummary]: """Resolve the merged template list a wizard should offer. Walks :func:`search_dirs` nearest-first, scanning each directory with :func:`list_templates`, and merges the results by template name keeping the **first** (nearest-scope) occurrence -- so a per-project template shadows a same-named per-equipment or global one. Nearest-first order is preserved in the result. ``template_type`` is always passed to :func:`list_templates`: the global dir is flat (mixed types) so the filter is required, and per-instance ``<type>/`` dirs pass it defensively so a misfiled template of the wrong type is skipped rather than offered. For run templates, ``run_scope`` additionally narrows by the template's declared scope: when given (``"experimental"`` / ``"test"``) a run template is kept only when its ``run_scope`` equals that scope or is ``"both"``. ``run_scope=None`` keeps every run template. The parameter is ignored for non-run template types. Args: config: The loaded config. template_type: One of ``"project"`` / ``"run"`` / ``"equipment"``. equipment_id: The equipment the wizard runs under, if any. project_path: The absolute project directory, if any (run wizard). run_scope: Optional run-scope filter (run templates only). Returns: The merged, de-duplicated list of templates, nearest scope first. """ # Lazy import to avoid an import cycle: ``ui.pages.templates`` is part of # the UI package whose ``mount`` indirectly imports this resolver. from exlab_wizard.ui.pages.templates import list_templates merged: list[TemplateSummary] = [] seen: set[str] = set() for directory in search_dirs( config, template_type=template_type, equipment_id=equipment_id, project_path=project_path, ): for summary in list_templates(directory, template_type=template_type): if summary.name in seen: continue if not _run_scope_matches(template_type, run_scope, summary.run_scope): continue seen.add(summary.name) merged.append(summary) return merged
[docs] def reconcile_selection( choices: TemplateChoices, selected_name: str | None ) -> tuple[str | None, Path | None, bool]: """Reconcile a prior template selection against freshly-resolved choices. Called after a wizard re-resolves its template chain (the operator changed equipment / project). Returns ``(name, path, dropped)``: * The selected name **survives** the new context -> its resolved path is **re-derived** from ``choices`` and returned with ``dropped=False``. This is the critical case: a same-named per-instance template shadows the global one at a *different* path, and a wizard ``ui.select`` that keeps its value does not re-fire its change handler -- so the stored path must be refreshed here or submit would render the stale source. * The selected name is **gone** (or was ``None``) -> returns ``(None, None, True)`` so the caller clears the selection and its now-orphaned variables. Args: choices: The freshly-resolved templates for the new context. selected_name: The template name selected under the old context. Returns: ``(name, path, dropped)`` -- the reconciled selection name, its re-derived absolute path (or ``None``), and whether the prior selection was dropped. """ if selected_name is not None and selected_name in choices.names: return selected_name, choices.paths.get(selected_name), False return None, None, True
[docs] def project_dir(config: Config, equipment_id: str | None, project_name: str | None) -> Path | None: """Return the absolute project directory, or ``None`` when not derivable. A run's per-project template store lives under ``<local_root>/<equipment_id>/<project_name>/`` (Backend Spec §3.2). The wizard knows the equipment id and the parent project's folder name, so this composes the path the run-wizard resolver passes as ``project_path``. Returns ``None`` when ``local_root``, ``equipment_id``, or ``project_name`` is missing -- the caller then resolves without the per-project scope (per-equipment + global only). """ from pathlib import Path local_root = config.paths.local_root if not local_root or not equipment_id or not project_name: return None return Path(local_root) / equipment_id / project_name
def _global_dir(config: Config) -> Path | None: """Return the global ``paths.templates_dir`` as a ``Path``, or ``None``. ``None`` when the config value is empty, so the caller skips the scope. """ from pathlib import Path templates_dir = config.paths.templates_dir return Path(templates_dir) if templates_dir else None def _equipment_dir(config: Config, equipment_id: str | None) -> Path | None: """Return ``<local_root>/<equipment_id>`` as a ``Path``, or ``None``. ``None`` when ``equipment_id`` is unset or ``local_root`` is empty, so the caller skips the per-equipment scope. """ from pathlib import Path local_root = config.paths.local_root if not equipment_id or not local_root: return None return Path(local_root) / equipment_id def _run_scope_matches( template_type: str, requested_scope: str | None, template_scope: str | None, ) -> bool: """Return whether a template passes the run-scope filter. Only run templates with a requested scope are narrowed. A run template is kept when its scope equals the requested scope or is :attr:`RunScope.BOTH`. Non-run types and an unset ``requested_scope`` always pass. """ if template_type != TemplateType.RUN.value or requested_scope is None: return True return template_scope in (requested_scope, RunScope.BOTH.value)