Source code for exlab_wizard.controller.metadata_assembly

"""Shared metadata value-assembly. Backend Spec §10 / §11.3; design spec §5.

This module owns the two pure value-assembly steps that turn a creation
request into a :class:`~exlab_wizard.readme.generator.ReadmeContext` and a
:class:`~exlab_wizard.api.schemas.CreationJson`. Both the
:class:`~exlab_wizard.controller.creation.CreationController` and the
sample-data seeder call them, so the on-disk metadata they produce can
never drift between the two paths.

The helpers take explicit parameters instead of reading a controller
``self``: a :class:`Config`, the equipment id, the core fields, the
partitioned ``readme_extra``, a lightweight :class:`TemplateDesc` (a
stand-in for ``ResolvedTemplate`` so this module never imports Copier),
and injected ``created`` / ``created_by`` / ``created_at_iso`` values (so
the output is deterministic and the controller keeps stamping wall-clock
time while the seeder stamps a fixed clock).

Importantly this module MUST NOT import
``exlab_wizard.controller.creation`` -- the pure helpers it needs were
moved *here* (``_readme_decls_from_template`` / ``_readme_decls_from_config``
/ ``_os_username``) and ``creation.py`` re-imports them from this module.
"""

from __future__ import annotations

import getpass
import os
import socket
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any

from exlab_wizard.api.schemas import (
    CreationJson,
    LimsProjectBlock,
    OrchestratorBlock,
    PathsBlock,
    PluginApplied,
    TemplateBlock,
)
from exlab_wizard.config.models import Config
from exlab_wizard.constants import (
    CREATION_JSON_VERSION,
    CreationLevel,
    FieldType,
    RunKind,
    RunScope,
    SyncStatus,
)
from exlab_wizard.readme import (
    CoreFields,
    CustomField,
    ReadmeContext,
    SystemFields,
    TemplateFieldDecl,
)
from exlab_wizard.template.copier_driver import CORE_README_FIELD_IDS

__all__ = [
    "TemplateDesc",
    "build_creation_json",
    "build_readme_context",
]


[docs] @dataclass(frozen=True) class TemplateDesc: """Dependency-light stand-in for ``ResolvedTemplate``. Design spec §5. Carries only the template provenance the assembly helpers need, so a caller can supply it without resolving a Copier template (the seeder passes a sentinel; the controller adapts its ``ResolvedTemplate``). Attributes: name: Template name -- maps to ``ResolvedTemplate.name``. version: Template version -- maps to ``ResolvedTemplate.exlab_version``. source_path: Stringified template source path -- maps to ``str(ResolvedTemplate.path)``. run_scope: The run-scope tag persisted on ``creation.json``'s template block; ``None`` for project/equipment templates. provenance_path: Path (relative to the instance dir, POSIX) of the frozen verbatim template copy written under ``.exlab-wizard/templates/...`` at creation time; empty when no copy was made. extra_readme_fields: ``_exlab_readme.fields`` entries (free-form dicts) used to build the template-layer field declarations. plugin_order: Plugin slug ordering (unused by these helpers but kept for symmetry with ``ResolvedTemplate``). """ name: str version: str source_path: str run_scope: RunScope | None = None provenance_path: str = "" extra_readme_fields: list[dict[str, Any]] = field(default_factory=list) plugin_order: list[str] = field(default_factory=list)
[docs] def build_readme_context( *, config: Config, equipment_id: str, level: CreationLevel, label: str, operator: str, objective: str, readme_extra: dict[str, Any], template: TemplateDesc, short_id: str, run_name: str | None, run_kind_value: str, created: datetime, created_by: str, ) -> ReadmeContext: """Compose the §10 four-layer :class:`ReadmeContext`. Maps the template's ``_exlab_readme.fields`` and the config ``readme.defaults`` into typed field declarations, partitions the operator-supplied ``readme_extra`` values across the template / config / custom layers by id, and fills the auto-managed system block (Backend Spec §10.6) from the injected ``created`` / ``created_by``. """ template_decls = _readme_decls_from_template(template.extra_readme_fields) config_decls = _readme_decls_from_config(config.readme.defaults) template_ids = {decl.id for decl in template_decls} config_ids = {decl.id for decl in config_decls} template_fields: dict[str, Any] = {} config_fields: dict[str, Any] = {} custom_fields: list[CustomField] = [] for key, value in readme_extra.items(): if key in template_ids: template_fields[key] = value elif key in config_ids: config_fields[key] = value elif key in CORE_README_FIELD_IDS: # Core fields live in their own layer; never echoed as custom. continue else: custom_fields.append(CustomField(label=key, value="" if value is None else str(value))) is_run = level is CreationLevel.RUN equipment = next( (entry for entry in config.equipment if entry.id == equipment_id), None, ) system = SystemFields( created=created, created_by=created_by, equipment={"id": equipment_id, "label": equipment.label if equipment else ""}, template={"name": template.name, "version": template.version}, # §10.6: ``project`` is the machine-safe LIMS short id recorded in # README metadata (§3.1) -- distinct from the human-readable # ``<project>/`` folder segment. ``run`` is the run directory name. project=short_id, run=run_name if is_run else None, run_kind=run_kind_value if is_run else "", ) return ReadmeContext( level=level, core=CoreFields(label=label, operator=operator, objective=objective), template_fields=template_fields, config_fields=config_fields, custom_fields=custom_fields, system=system, template_field_decls=template_decls, config_field_decls=config_decls, )
[docs] def build_creation_json( *, config: Config, equipment_id: str, operator: str, level: CreationLevel, run_kind_value: str, lims_block: LimsProjectBlock, template: TemplateDesc, variables: dict[str, Any], dst: Path, nas_root: str, plugins_applied: list[PluginApplied], created_at_iso: str, sync_status: SyncStatus = SyncStatus.PENDING, ) -> CreationJson: """Assemble the §11.3 :class:`CreationJson` payload. Redesign §3.1: creation.json always carries the orchestrator block. Redesign §3.3: the block carries the producing equipment's label so a receiving orchestrator can auto-discover the relayed equipment without a per-equipment config of its own. ``sync_status`` is injectable (controller passes ``PENDING``; the seeder passes the per-run scenario) and ``created_at`` is the injected ``created_at_iso``. """ eq = next((e for e in config.equipment if e.id == equipment_id), None) orchestrator_block = OrchestratorBlock( enabled=True, host=socket.gethostname(), label=config.orchestrator.label, equipment_label=eq.label if eq else None, ) return CreationJson( schema_version=CREATION_JSON_VERSION, created_at=created_at_iso, created_by=operator, level=level, run_kind=RunKind(run_kind_value), lims_project=lims_block, template=TemplateBlock( name=template.name, version=template.version, source_path=template.source_path, run_scope=template.run_scope, provenance_path=template.provenance_path, ), variables=dict(variables), paths=PathsBlock( local=str(dst), nas=str(Path(nas_root) / equipment_id) if nas_root else "", ), plugins_applied=plugins_applied, orchestrator=orchestrator_block, sync_status=sync_status, )
# --------------------------------------------------------------------------- # Pure helpers (moved out of controller/creation.py) # --------------------------------------------------------------------------- def _readme_decls_from_template(entries: list[dict[str, Any]]) -> list[TemplateFieldDecl]: """Map a template's ``_exlab_readme.fields`` dicts to typed declarations. Entries without a string ``id`` are skipped (mirrors :func:`_required_field_ids`); ``type`` is coerced to :class:`~exlab_wizard.constants.FieldType` so the generator can type-check values against it. An unknown ``type`` raises ``ValueError``, which the pipeline surfaces as a failed creation. """ decls: list[TemplateFieldDecl] = [] for entry in entries: if not isinstance(entry, dict): continue fid = entry.get("id") if not isinstance(fid, str) or not fid: continue options = entry.get("options") hint = entry.get("hint") decls.append( TemplateFieldDecl( id=fid, label=str(entry.get("label", fid)), type=FieldType(str(entry.get("type", FieldType.STRING.value))), required=bool(entry.get("required", False)), default=entry.get("default", ""), options=list(options) if isinstance(options, list) else None, hint=hint if isinstance(hint, str) else None, ) ) return decls def _readme_decls_from_config(defaults: list[Any]) -> list[TemplateFieldDecl]: """Map ``config.readme.defaults`` entries to typed declarations. Core field ids are dropped -- they are backend-managed and live in their own layer (Backend Spec §10.3), matching the required-field gate in :meth:`CreationController._validate_inputs`. """ decls: list[TemplateFieldDecl] = [] for entry in defaults: if entry.id in CORE_README_FIELD_IDS: continue decls.append( TemplateFieldDecl( id=entry.id, label=entry.label, type=entry.type, required=entry.required, default=entry.default, options=list(entry.options) if entry.options else None, hint=entry.hint, ) ) return decls def _os_username() -> str: """Return the creating OS user for the README ``system.created_by``. Distinct from the experiment ``operator`` (Backend Spec §10.6). Falls back to the ``USER`` / ``USERNAME`` environment variables and finally ``"unknown"`` when the platform cannot report a login name. """ try: return getpass.getuser() except Exception: return os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"