diff --git a/src/exlab_wizard/config/test_bootstrap.py b/src/exlab_wizard/config/test_bootstrap.py new file mode 100644 index 0000000..0a666d8 --- /dev/null +++ b/src/exlab_wizard/config/test_bootstrap.py @@ -0,0 +1,83 @@ +"""Starter ``config.yaml`` bootstrap for the ``-test`` sandbox. + +Shared by the tray's ``--test`` flag (:mod:`exlab_wizard.tray.main`) and the +standalone dev seeder (:mod:`exlab_wizard.dev.seed`). Lives in the light +``config`` package so the dev command can reuse it without importing the heavy +``tray`` package (whose ``__init__`` pulls in pystray and the server stack). +""" + +from __future__ import annotations + +from pathlib import Path + +from exlab_wizard.logging import get_logger + +_log = get_logger(__name__) + + +def write_starter_test_config(config_path: Path) -> None: + """Write a starter test ``config.yaml`` if one does not already exist. + + Preseeds every path-typed field under the test sandbox + (``config_path.parent``) so the wizard runs without manual Settings entry; + the LIMS endpoint and email are intentionally left blank so the operator + still wires that integration through the live Settings UI. Equipment is + left empty -- sample equipment is seeded separately by + :func:`exlab_wizard.sample_data.generate_samples`. + + Idempotent: an existing config is never overwritten. The sandbox is + persistent across launches and the operator resets it by deleting the + suffixed directory. + """ + if config_path.exists(): + return + + # Lazy imports keep this module's import graph light for callers that only + # need the function lazily (the tray test path and the dev command). + from exlab_wizard.config.loader import save_config + from exlab_wizard.config.models import Config, OrchestratorConfig, PathsConfig + from exlab_wizard.paths import ensure_dir + + sandbox = config_path.parent # e.g. ~/Library/Application Support/exlab-wizard-test + paths_cfg = PathsConfig( + templates_dir=str(sandbox / "templates"), + plugin_dir=str(sandbox / "plugins"), + local_root=str(sandbox / "local"), + ) + orchestrator_cfg = OrchestratorConfig( + label="test-workstation", + staging_root=str(sandbox / "staging"), + ) + cfg = Config(paths=paths_cfg, orchestrator=orchestrator_cfg, equipment=[]) + save_config(config_path, cfg) + + # Pre-create the preseeded sub-directories so first-launch path lookups + # (template scans, plugin discovery) do not fail on a missing tree. + for sub in ( + paths_cfg.templates_dir, + paths_cfg.plugin_dir, + paths_cfg.local_root, + orchestrator_cfg.staging_root, + ): + if sub: + ensure_dir(Path(sub)) + + _log.info("test mode: wrote starter config [path=%s]", str(config_path)) + + +def bootstrap_test_config(config_path: Path, *, include_samples: bool) -> None: + """Tray ``--test`` bootstrap: write the starter config, optionally seed samples. + + No-op when a config already exists -- the sandbox is persistent, so a + repeat ``--test`` boot must neither overwrite the config nor re-seed (and + never wipes). On a fresh sandbox, writes the starter config and, when + ``include_samples`` (``--add-test-samples``), expands the declarative + ``SAMPLES`` tree through the shared generator in non-destructive mode. + """ + if config_path.exists(): + return + write_starter_test_config(config_path) + if include_samples: + from exlab_wizard.sample_data import generate_samples + + generate_samples(config_path, wipe=False) diff --git a/src/exlab_wizard/controller/creation.py b/src/exlab_wizard/controller/creation.py index edd207c..430f54a 100644 --- a/src/exlab_wizard/controller/creation.py +++ b/src/exlab_wizard/controller/creation.py @@ -33,10 +33,7 @@ import asyncio import contextlib -import getpass -import os import shutil -import socket from collections.abc import AsyncIterator from dataclasses import dataclass, field from datetime import UTC, datetime @@ -47,11 +44,8 @@ CreationJson, EquipmentJson, LimsProjectBlock, - OrchestratorBlock, - PathsBlock, PluginApplied, PluginIsolation, - TemplateBlock, ) from exlab_wizard.cache.creation_writer import CreationWriter from exlab_wizard.cache.equipment import EquipmentCacheWriter @@ -60,14 +54,12 @@ from exlab_wizard.constants import ( ANSWERS_FILE_NAME, CACHE_DIR_NAME, - CREATION_JSON_VERSION, EQUIPMENT_JSON_VERSION, LABEL_MAX_LENGTH, LOG_FILE_TEMPLATE, OBJECTIVE_MAX_LENGTH, README_FILE_NAME, CreationLevel, - FieldType, LIMSProjectSource, PluginStatus, RunKind, @@ -76,6 +68,12 @@ TemplateType, Tier, ) +from exlab_wizard.controller.metadata_assembly import ( + TemplateDesc, + _os_username, + build_creation_json, + build_readme_context, +) from exlab_wizard.controller.session_store import Session, SessionStore from exlab_wizard.controller.state_machine import ( Phase, @@ -100,11 +98,7 @@ from exlab_wizard.plugins.host import InputRequiredPayload, PluginHost, PluginPassResult from exlab_wizard.plugins.logger import HostPluginLogger from exlab_wizard.readme import ( - CoreFields, - CustomField, ReadmeContext, - SystemFields, - TemplateFieldDecl, ) from exlab_wizard.template.copier_driver import ( CORE_README_FIELD_IDS, @@ -911,53 +905,29 @@ def _build_readme_context( block (Backend Spec §10.6). Reads ``self._config`` at call time so a live settings reload is reflected on the next creation. """ - template_decls = _readme_decls_from_template(resolved.extra_readme_fields) - config_decls = _readme_decls_from_config(self._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 req.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 = isinstance(req, RunCreateRequest) - equipment = next( - (entry for entry in self._config.equipment if entry.id == req.equipment_id), - None, + desc = TemplateDesc( + name=resolved.name, + version=resolved.exlab_version, + source_path=str(resolved.path), + run_scope=resolved.run_scope, + extra_readme_fields=resolved.extra_readme_fields, + plugin_order=resolved.plugin_order, ) - system = SystemFields( + return build_readme_context( + config=self._config, + equipment_id=req.equipment_id, + level=CreationLevel.RUN if is_run else CreationLevel.PROJECT, + label=req.label, + operator=req.operator, + objective=req.objective, + readme_extra=req.readme_extra, + template=desc, + short_id=self._short_id_for(req), + run_name=dst.name if is_run else None, + run_kind_value=self._run_kind_value_for(req) if is_run else "", created=utc_now(), created_by=_os_username(), - equipment={"id": req.equipment_id, "label": equipment.label if equipment else ""}, - template={"name": resolved.name, "version": resolved.exlab_version}, - # §10.6: ``project`` is the machine-safe LIMS short id recorded in - # README metadata (§3.1) -- distinct from the human-readable - # ``/`` folder segment. ``run`` is the run directory name. - project=self._short_id_for(req), - run=dst.name if is_run else None, - run_kind=self._run_kind_value_for(req) if is_run else "", - ) - return ReadmeContext( - level=CreationLevel.RUN if is_run else CreationLevel.PROJECT, - core=CoreFields(label=req.label, operator=req.operator, objective=req.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, ) async def _write_cache( @@ -1032,39 +1002,31 @@ async def _write_cache( for entry in plugin_result.applied ] - # 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. - eq = next((e for e in self._config.equipment if e.id == req.equipment_id), None) - orchestrator_block = OrchestratorBlock( - enabled=True, - host=socket.gethostname(), - label=self._config.orchestrator.label, - equipment_label=eq.label if eq else None, + # Redesign §3.1/§3.3: the orchestrator block, template/paths blocks, + # and the CreationJson assembly are shared with the sample-data + # seeder via ``build_creation_json`` so the two can never drift. + desc = TemplateDesc( + name=resolved.name, + version=resolved.exlab_version, + source_path=str(resolved.path), + run_scope=resolved.run_scope, + extra_readme_fields=resolved.extra_readme_fields, + plugin_order=resolved.plugin_order, ) - - payload = CreationJson( - schema_version=CREATION_JSON_VERSION, - created_at=utc_now_iso(), - created_by=req.operator, + payload = build_creation_json( + config=self._config, + equipment_id=req.equipment_id, + operator=req.operator, level=level_value, - run_kind=RunKind(run_kind_value), - lims_project=lims_block, - template=TemplateBlock( - name=resolved.name, - version=resolved.exlab_version, - source_path=str(resolved.path), - run_scope=resolved.run_scope, - ), - variables=dict(req.variables), - paths=PathsBlock( - local=str(dst), - nas=str(Path(nas_root) / req.equipment_id) if nas_root else "", - ), + run_kind_value=run_kind_value, + lims_block=lims_block, + template=desc, + variables=req.variables, + dst=dst, + nas_root=nas_root, plugins_applied=plugins_applied, - orchestrator=orchestrator_block, sync_status=SyncStatus.PENDING, + created_at_iso=utc_now_iso(), ) await self._cache_creation.write_creation(cache_path, payload) @@ -1266,76 +1228,6 @@ def _required_field_ids(extra_fields: list[dict[str, Any]]) -> tuple[str, ...]: return tuple(out) -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" - - def _has_hard_finding(findings: list[Finding]) -> bool: return any(f.tier == Tier.HARD.value for f in findings) diff --git a/src/exlab_wizard/controller/metadata_assembly.py b/src/exlab_wizard/controller/metadata_assembly.py new file mode 100644 index 0000000..691729a --- /dev/null +++ b/src/exlab_wizard/controller/metadata_assembly.py @@ -0,0 +1,297 @@ +"""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", +] + + +@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. + 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 + extra_readme_fields: list[dict[str, Any]] = field(default_factory=list) + plugin_order: list[str] = field(default_factory=list) + + +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 + # ``/`` 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, + ) + + +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, + ), + 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" diff --git a/src/exlab_wizard/dev/__init__.py b/src/exlab_wizard/dev/__init__.py new file mode 100644 index 0000000..988660d --- /dev/null +++ b/src/exlab_wizard/dev/__init__.py @@ -0,0 +1,6 @@ +"""Developer utilities. Not part of the shipped application surface. + +Modules here are run by hand during development (e.g. ``python -m +exlab_wizard.dev.seed``) and are never imported by the tray, server, or +window entry points. +""" diff --git a/src/exlab_wizard/dev/seed.py b/src/exlab_wizard/dev/seed.py new file mode 100644 index 0000000..918bbed --- /dev/null +++ b/src/exlab_wizard/dev/seed.py @@ -0,0 +1,44 @@ +"""Standalone sample-data seeder -- ``python -m exlab_wizard.dev.seed``. + +Forces the ``-test`` sandbox, ensures a starter ``config.yaml`` exists, then +**wipes and regenerates** the declarative ``SAMPLES`` tree (design spec §8). +Decoupled from tray boot so the async generation runs cleanly on its own event +loop. Always wipes -- that is the only mode; the generator prints the exact +directories it will remove before removing them (guardrailed; design spec §7). +""" + +from __future__ import annotations + +import os + + +def main(argv: list[str] | None = None) -> int: + """Regenerate the ``-test`` sample-data tree from scratch. + + Returns the process exit code (0 on success). + """ + # Set test mode BEFORE importing any paths.py helper so the state-dir and + # config-path lookups both resolve under the '-test' suffixed sandbox + # (mirrors tray/main.py's ordering). + from exlab_wizard import paths + + os.environ[paths.TEST_MODE_ENV] = "1" + + from exlab_wizard.config.test_bootstrap import write_starter_test_config + from exlab_wizard.paths import ensure_state_dir, os_config_path + from exlab_wizard.sample_data import generate_samples + + ensure_state_dir() + config_path = os_config_path() + # Ensure a base config exists (no-op if the sandbox is already set up); + # generate_samples then loads it, merges the sample equipment, and seeds. + write_starter_test_config(config_path) + + print(f"sample-data seed: regenerating sample tree under {config_path.parent}") + generate_samples(config_path, wipe=True) + print("sample-data seed: done.") + return 0 + + +if __name__ == "__main__": # pragma: no cover -- script entrypoint + raise SystemExit(main()) diff --git a/src/exlab_wizard/sample_data/__init__.py b/src/exlab_wizard/sample_data/__init__.py new file mode 100644 index 0000000..c6993e7 --- /dev/null +++ b/src/exlab_wizard/sample_data/__init__.py @@ -0,0 +1,16 @@ +"""Declarative sample-data fixtures for the ``-test`` sandbox. + +This package owns the single place a developer edits to change the seeded demo +dataset (:mod:`exlab_wizard.sample_data.spec`) and the ``generate_samples`` +facade (:mod:`exlab_wizard.sample_data.generator`) that expands it into a real +on-disk tree under the ``-test`` sandbox, writing each folder's metadata +through the production producers so the data cannot drift from the real format. + +The facade is re-exported here for callers (the tray flag, the dev command). +``spec`` stays import-safe on its own; importing the facade pulls the generator +(and the producers it wires) only when the symbol is actually used. +""" + +from exlab_wizard.sample_data.generator import SampleDataGenerator, generate_samples + +__all__ = ["SampleDataGenerator", "generate_samples"] diff --git a/src/exlab_wizard/sample_data/generator.py b/src/exlab_wizard/sample_data/generator.py new file mode 100644 index 0000000..0182294 --- /dev/null +++ b/src/exlab_wizard/sample_data/generator.py @@ -0,0 +1,489 @@ +"""Expand the declarative ``SAMPLES`` list into an on-disk demo tree. + +Design spec §6 (generation flow), §7 (guardrails), §8 (module layout). + +The generator turns each :class:`~exlab_wizard.sample_data.spec.SampleEquipment` +into a real folder tree under the ``-test`` sandbox, writing every folder's +metadata through the **production producers** (``ReadmeGenerator``, +``CreationWriter``, ``EquipmentCacheWriter``) and the Phase 1 shared value +helpers (``build_readme_context`` / ``build_creation_json``). Because the same +code paths the real wizard uses produce the bytes, the seeded metadata can +never drift from what creation writes. + +Sourcing mirrors ``controller/creation.py`` exactly (``_compose_destination_path``, +``_write_cache``, ``_write_equipment_json``): + +- folder composition uses ``config.paths.local_root`` (the base) with the + **prefixed** equipment id (e.g. ``TEST_TESTRIG``); +- ``equipment.json`` lives under ``config.paths.local_root / `` + with ``configured_local_root = str(config.paths.local_root)`` and + ``configured_nas_root`` taken from the equipment entry's ``nas_root``; +- ``creation.json``'s ``paths.nas`` is composed from the equipment entry's + ``nas_root`` and the prefixed id. + +Determinism: a fixed ``base_time`` is the base instant; each run is stamped at +``base_time + minutes_offset`` (or a per-project running index when the sample +leaves ``minutes_offset`` unset). The only non-deterministic bytes are +``equipment.json``'s ``first_seen_at`` / ``last_modified_at``, which the writer +stamps with wall-clock (documented and excluded from determinism assertions). + +Safety: the destructive wipe is fenced behind the §7 guardrails -- it refuses +to run unless the resolved app name ends in ``-test`` (which holds only when +``EXLAB_WIZARD_TEST_MODE == "1"``), only ever removes ``local_root/`` +for ids in ``SAMPLES``, and asserts each target is a strict subpath of the sandbox. +""" + +from __future__ import annotations + +import asyncio +import shutil +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from exlab_wizard.api.schemas import ( + EquipmentJson, + LimsProjectBlock, + TestRunsJson, +) +from exlab_wizard.cache.creation_writer import CreationWriter +from exlab_wizard.cache.equipment import EquipmentCacheWriter +from exlab_wizard.config.loader import load_config, save_config +from exlab_wizard.config.models import ( + Config, + EquipmentConfig, + OperatorsConfig, + READMEConfig, + READMEDefaultField, +) +from exlab_wizard.constants import ( + EQUIPMENT_JSON_VERSION, + TEST_MODE_ENV, + TEST_RUNS_JSON_NAME, + TEST_RUNS_JSON_VERSION, + CreationLevel, + FieldType, + LIMSProjectSource, + RunKind, + RunScope, + SyncStatus, +) +from exlab_wizard.controller.metadata_assembly import ( + TemplateDesc, + build_creation_json, + build_readme_context, +) +from exlab_wizard.logging import get_logger +from exlab_wizard.paths import ( + _app_name, + cache_dir, + compose_project_path, + compose_run_path, + creation_json_path, + ensure_dir, + equipment_json_path, +) +from exlab_wizard.readme import ReadmeGenerator +from exlab_wizard.sample_data.spec import SAMPLES, SampleEquipment, SampleProject, SampleRun +from exlab_wizard.utils.time import dt_to_iso + +__all__ = ["SampleDataGenerator", "generate_samples"] + +_log = get_logger(__name__) + +# The single non-required README config default the seeder injects so a run's +# ``readme_extra`` can populate the config_fields layer (design spec §6 step 1). +_SAMPLE_TYPE_DEFAULT = READMEDefaultField( + id="sample_type", + label="Sample Type", + type=FieldType.CHOICE, + required=False, + options=["control", "treatment"], +) + +# Sentinel template provenance: the seeder never resolves a Copier template, so +# it passes a fixed stand-in (design spec §5/§6 step 4). +_SEED_TEMPLATE = TemplateDesc( + name="seed", + version="0", + source_path="", + run_scope=RunScope.BOTH, + extra_readme_fields=[], + plugin_order=[], +) + +# Default payload pair written under a run dir when ``SampleRun.files`` is None. +_DEFAULT_FILES: tuple[tuple[str, str], ...] = ( + ("data/acq_001.csv", "t,value\n0,0.0\n1,1.0\n"), + ("notes.txt", "Seeded demo run.\n"), +) + + +def generate_samples( + config_path: Path, + *, + wipe: bool, + base_time: datetime = datetime(2026, 1, 1, 9, 0, tzinfo=UTC), +) -> None: + """Expand ``SAMPLES`` into an on-disk demo tree under the sandbox. + + Synchronous facade over the async core (the producers are async). Loads + the config at ``config_path``, merges the sample equipment + the one + seeded README default into it, reloads so the ``TEST_`` prefix is stamped, + optionally wipes the seeded subtrees (guardrailed), then writes every + folder's metadata through the real producers. + + Args: + config_path: Path to ``config.yaml`` in the sandbox. The sandbox dir + is ``config_path.parent``. + wipe: When True, remove ``local_root/`` for each seeded + equipment before regenerating (after the §7 guardrail check). When + False, the existing tree is left in place and never deleted. + base_time: The fixed base instant; per-run timestamps are + ``base_time + minutes_offset``. + """ + SampleDataGenerator(config_path=config_path, base_time=base_time).generate(wipe=wipe) + + +class SampleDataGenerator: + """Expands :data:`SAMPLES` into an on-disk tree (design spec §6).""" + + def __init__( + self, + *, + config_path: Path, + base_time: datetime = datetime(2026, 1, 1, 9, 0, tzinfo=UTC), + ) -> None: + self._config_path = Path(config_path) + self._sandbox = self._config_path.parent + self._base_time = base_time + self._readme = ReadmeGenerator() + self._creation = CreationWriter() + self._equipment = EquipmentCacheWriter() + + def generate(self, *, wipe: bool) -> None: + """Synchronous wrapper running the async core on a fresh loop.""" + asyncio.run(self._generate_async(wipe=wipe)) + + # ------------------------------------------------------------------ + # Async core + # ------------------------------------------------------------------ + + async def _generate_async(self, *, wipe: bool) -> None: + config = self._build_and_reload_config() + local_root = Path(config.paths.local_root) + + # The loaded config carries the prefixed ids in SAMPLES order, so we + # can pair each SampleEquipment with its prefixed EquipmentConfig. + if len(config.equipment) != len(SAMPLES): # pragma: no cover - defensive + msg = ( + "loaded equipment count does not match SAMPLES; " + f"expected {len(SAMPLES)}, got {len(config.equipment)}" + ) + raise RuntimeError(msg) + + if wipe: + self._wipe(config) + + for sample_eq, eq_cfg in zip(SAMPLES, config.equipment, strict=True): + await self._write_equipment(config, sample_eq, eq_cfg, local_root) + + # ------------------------------------------------------------------ + # Step 1 -- build & reload config + # ------------------------------------------------------------------ + + def _build_and_reload_config(self) -> Config: + """Merge sample equipment + the seeded README default; reload prefixed. + + Converts each :class:`SampleEquipment` into an :class:`EquipmentConfig` + with **raw** ids and sandbox-derived roots (the loader stamps the + ``TEST_`` prefix on reload), seeds the single ``sample_type`` README + default, and clears the operators allowlist. Mirrors the field shape + of today's ``tray/main.py:_bootstrap_test_config`` literal. + """ + config = load_config(self._config_path) + + equipment = [ + EquipmentConfig( + id=sample.id, # raw; the loader prefixes on reload + label=sample.label, + # ``local_root`` / ``nas_root`` are the BASE roots: consumers + # (orchestrator quiescence poller, validator) compose + # ``Path(local_root) / equipment.id``, and ``build_creation_json`` + # composes ``Path(nas_root) / equipment_id`` -- so the id is + # appended downstream, never baked in here (matches a real + # operator config and ``config.paths.local_root``). + local_root=str(self._sandbox / "local"), + nas_root=str(self._sandbox / "nas"), + sync_mode=sample.sync_mode, + ) + for sample in SAMPLES + ] + + merged = config.model_copy( + update={ + "equipment": equipment, + "readme": READMEConfig(defaults=[_SAMPLE_TYPE_DEFAULT]), + "operators": OperatorsConfig(allowlist=[]), + } + ) + save_config(self._config_path, merged) + + # Reload so apply_test_mode_prefix stamps the TEST_ prefix; from here + # the loaded (prefixed) ids drive every path. + return load_config(self._config_path) + + # ------------------------------------------------------------------ + # Step 2 -- guardrailed wipe (design spec §7) + # ------------------------------------------------------------------ + + def _wipe(self, config: Config) -> None: + """Remove ``local_root/`` for each seeded equipment. + + Enforces every §7 guardrail before touching disk and prints the exact + list of directories it will remove (visibility requirement). Raises + loudly -- never a silent no-op -- when a precondition fails. + """ + # The wipe only fires inside the test sandbox. ``_app_name()`` returns + # the ``-test`` suffix exactly when EXLAB_WIZARD_TEST_MODE == "1", so + # this single check is both the test-mode gate and the sandbox gate. + app_name = _app_name() + if not app_name.endswith("-test"): + msg = ( + f"refusing to wipe: resolved app name {app_name!r} does not end " + f"in '-test'. The destructive wipe only runs inside the test " + f"sandbox (set {TEST_MODE_ENV}=1); a real local_root must never " + "be deletable here." + ) + raise RuntimeError(msg) + + local_root = Path(config.paths.local_root) + sandbox = self._sandbox.resolve() + + # Only ids present in SAMPLES (carried through to the loaded, prefixed + # config.equipment) are eligible. Resolve + containment-check each. + targets: list[Path] = [] + for entry in config.equipment: + target = (local_root / entry.id).resolve() + if target == sandbox or sandbox not in target.parents: + msg = f"refusing to wipe {target}: not a strict subpath of the sandbox {sandbox}." + raise RuntimeError(msg) + if target == local_root.resolve(): + msg = f"refusing to wipe {target}: that is local_root itself." + raise RuntimeError(msg) + targets.append(target) + + print("sample-data wipe: removing the following directories:") + for target in targets: + print(f" {target}") + for target in targets: + if target.exists(): + shutil.rmtree(target) + + # ------------------------------------------------------------------ + # Steps 3-4 -- create tree + write metadata + # ------------------------------------------------------------------ + + async def _write_equipment( + self, + config: Config, + sample_eq: SampleEquipment, + eq_cfg: EquipmentConfig, + local_root: Path, + ) -> None: + prefixed_id = eq_cfg.id + nas_root = eq_cfg.nas_root + + for project in sample_eq.projects: + await self._write_project(config, prefixed_id, nas_root, local_root, project) + + # equipment.json once per equipment, under local_root/. + equipment_dir = local_root / prefixed_id + payload = EquipmentJson( + schema_version=EQUIPMENT_JSON_VERSION, + id=prefixed_id, + label=sample_eq.label, + configured_local_root=str(local_root), + configured_nas_root=nas_root, + # Placeholders; the writer overwrites both with wall-clock time. + first_seen_at=dt_to_iso(self._base_time), + last_modified_at=dt_to_iso(self._base_time), + ) + await self._equipment.write_equipment(equipment_json_path(equipment_dir), payload) + + async def _write_project( + self, + config: Config, + prefixed_id: str, + nas_root: str, + local_root: Path, + project: SampleProject, + ) -> None: + project_dir = compose_project_path( + local_root=local_root, + equipment_id=prefixed_id, + project_name=project.name, + ) + ensure_dir(project_dir) + + lims_block = LimsProjectBlock( + uid="", + short_id=project.short_id, + name_at_creation=project.name, + source=LIMSProjectSource.LIVE, + ) + + # Project-level metadata (base instant; PENDING -- the controller + # always writes PENDING at the project level). + await self._write_metadata( + config=config, + prefixed_id=prefixed_id, + nas_root=nas_root, + dst=project_dir, + level=CreationLevel.PROJECT, + label=project.label, + operator=project.operator, + objective=project.objective, + readme_extra={}, + short_id=project.short_id, + run_name=None, + run_kind_value="", + lims_block=lims_block, + sync_status=SyncStatus.PENDING, + instant=self._base_time, + ) + + marker_written = False + for index, run in enumerate(project.runs): + run_date = self._run_date(run, index) + run_dir = compose_run_path( + local_root=local_root, + equipment_id=prefixed_id, + project_name=project.name, + run_kind=run.kind, + run_date=run_date, + ) + ensure_dir(run_dir) + self._write_payload(run_dir, run) + + await self._write_metadata( + config=config, + prefixed_id=prefixed_id, + nas_root=nas_root, + dst=run_dir, + level=CreationLevel.RUN, + label=run.label, + operator=run.operator, + objective=run.objective, + readme_extra=dict(run.readme_extra), + short_id=project.short_id, + run_name=run_dir.name, + run_kind_value=run.kind.value, + lims_block=lims_block, + sync_status=run.sync_status, + instant=run_date, + ) + + # test_runs.json marker: written the FIRST time a TestRuns/ run is + # written under this project; the writer is idempotent regardless. + if run.kind is RunKind.TEST and not marker_written: + marker = TestRunsJson( + schema_version=TEST_RUNS_JSON_VERSION, + created_at=dt_to_iso(self._base_time), + project=project.short_id, + equipment=prefixed_id, + run_kind=RunKind.TEST, + ) + await self._equipment.write_test_runs_marker( + cache_dir(project_dir) / TEST_RUNS_JSON_NAME, marker + ) + marker_written = True + + async def _write_metadata( + self, + *, + config: Config, + prefixed_id: str, + nas_root: str, + dst: Path, + level: CreationLevel, + label: str, + operator: str, + objective: str, + readme_extra: dict[str, object], + short_id: str, + run_name: str | None, + run_kind_value: str, + lims_block: LimsProjectBlock, + sync_status: SyncStatus, + instant: datetime, + ) -> None: + """Write README + readme_fields.json + creation.json for one folder. + + Uses the Phase 1 shared helpers so the bytes match production exactly; + ``created`` / ``created_at`` flow from the injected ``instant`` and + ``created_by`` is the experiment operator (deterministic). + """ + # Parity with the controller (creation.py): create the ``.exlab-wizard/`` + # cache dir explicitly rather than relying on the README generator's + # side-effecting mkdir, so ``write_creation`` never races a missing dir. + ensure_dir(cache_dir(dst)) + + ctx = build_readme_context( + config=config, + equipment_id=prefixed_id, + level=level, + label=label, + operator=operator, + objective=objective, + readme_extra=readme_extra, + template=_SEED_TEMPLATE, + short_id=short_id, + run_name=run_name, + run_kind_value=run_kind_value, + created=instant, + created_by=operator, + ) + await self._readme.generate(dst, ctx) + + payload = build_creation_json( + config=config, + equipment_id=prefixed_id, + operator=operator, + level=level, + run_kind_value=run_kind_value or RunKind.EXPERIMENTAL.value, + lims_block=lims_block, + template=_SEED_TEMPLATE, + variables={}, + dst=dst, + nas_root=nas_root, + plugins_applied=[], + sync_status=sync_status, + created_at_iso=dt_to_iso(instant), + ) + await self._creation.write_creation(creation_json_path(dst), payload) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _run_date(self, run: SampleRun, index: int) -> datetime: + """Deterministic run instant: ``base_time + (minutes_offset or index+1)``. + + Auto offsets start at 1 (not 0) so a run never collides with the + project's base-instant metadata, and increment per run so minute- + precision run paths within a project are unique and reproducible. + """ + offset = run.minutes_offset if run.minutes_offset is not None else index + 1 + return self._base_time + timedelta(minutes=offset) + + def _write_payload(self, run_dir: Path, run: SampleRun) -> None: + """Write the run's payload files (or the default pair when None).""" + files = ( + [(f.relpath, f.content) for f in run.files] + if run.files is not None + else list(_DEFAULT_FILES) + ) + for relpath, content in files: + target = run_dir / relpath + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") diff --git a/src/exlab_wizard/sample_data/spec.py b/src/exlab_wizard/sample_data/spec.py new file mode 100644 index 0000000..0c4ed2d --- /dev/null +++ b/src/exlab_wizard/sample_data/spec.py @@ -0,0 +1,215 @@ +"""Declarative sample-data model + the ``SAMPLES`` list (design spec §4). + +``SAMPLES`` is the single place a developer edits to change the seeded demo +dataset. The generator (a later phase) expands this list into a real on-disk +tree under the ``-test`` sandbox, writing each folder's metadata through the +same producers the production creation pipeline uses, so the data is correct by +construction and cannot drift from the real format. + +Models follow the ``config/models.py`` house style: ``extra="forbid"`` (unknown +keys raise), ``str_strip_whitespace=True`` (incidental whitespace is trimmed), +and ``frozen=True`` (``SAMPLES`` is a static constant). The field validators +reuse the real id/name rules from :mod:`exlab_wizard.paths`, so malformed sample +data raises ``ValidationError`` at import time -- before any folder is touched. +Each validator catches the helper's ``ConfigError`` and re-raises it as +``ValueError`` so Pydantic surfaces it as a ``ValidationError``, mirroring +``EquipmentConfig._validate_equipment_id`` in ``config/models.py``. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from exlab_wizard.constants.enums import RunKind, SyncMode, SyncStatus +from exlab_wizard.errors import ConfigError + + +class SampleFile(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, frozen=True) + + relpath: str = Field(min_length=1) # path under the run dir, e.g. "data/acq_001.csv" + content: str = "" # deterministic UTF-8 content (kept small) + + @field_validator("relpath") + @classmethod + def _check_relpath(cls, v: str) -> str: + # The generator writes payload files at ``run_dir / relpath``; keep + # them contained -- reject absolute paths and ``..`` traversal so a + # sample can never escape its run directory. + from pathlib import PurePosixPath + + pure = PurePosixPath(v) + if pure.is_absolute() or ".." in pure.parts: + msg = f"relpath {v!r} must be a relative path under the run dir, without '..' segments" + raise ValueError(msg) + return v + + +class SampleRun(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, frozen=True) + + kind: RunKind + label: str = Field(min_length=1) + operator: str = Field(min_length=1) + objective: str = Field(min_length=1) + readme_extra: dict[str, Any] = Field(default_factory=dict) # config-default + custom values + sync_status: SyncStatus = SyncStatus.PENDING # chosen badge scenario for this run + files: list[SampleFile] | None = None # payload files; None -> a default pair + minutes_offset: int | None = None # deterministic run-date offset; auto if None + + +class SampleProject(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, frozen=True) + + short_id: str # LIMS short id (validated below) + name: str = Field(min_length=1) # human-readable / folder segment + label: str = Field(min_length=1) + operator: str = Field(min_length=1) + objective: str = Field(min_length=1) + runs: list[SampleRun] = Field(min_length=1) + + @field_validator("short_id") + @classmethod + def _check_short_id(cls, v: str) -> str: + from exlab_wizard.paths import validate_project_short_id + + try: + return validate_project_short_id(v) + except ConfigError as exc: + raise ValueError(str(exc)) from exc + + @field_validator("name") + @classmethod + def _check_name(cls, v: str) -> str: + # Fail-fast: a bad folder name raises ValidationError at import time + # rather than mid-generation. Same rule compose_project_path / + # compose_run_path enforce when the tree is written. + from exlab_wizard.paths import validate_project_name + + try: + return validate_project_name(v) + except ConfigError as exc: + raise ValueError(str(exc)) from exc + + +class SampleEquipment(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, frozen=True) + + id: str # raw id; TEST_ prefix applied by the loader + label: str = Field(min_length=1) + sync_mode: SyncMode = SyncMode.NAS + projects: list[SampleProject] = Field(min_length=1) + + @field_validator("id") + @classmethod + def _check_id(cls, v: str) -> str: + from exlab_wizard.paths import canonicalize_equipment_id + + try: + return canonicalize_equipment_id(v) + except ConfigError as exc: + raise ValueError(str(exc)) from exc + + +SAMPLES: list[SampleEquipment] = [ + # Equipment A -- one project, three runs, covers SYNCED + PENDING. + SampleEquipment( + id="TESTRIG", + label="Test Rig", + sync_mode=SyncMode.NAS, + projects=[ + SampleProject( + short_id="PROJ-0001", + name="Demo Project", + label="Demo Project", + operator="asmith", + objective="Exercise the browse / validate / sync UIs.", + runs=[ + SampleRun( + kind=RunKind.EXPERIMENTAL, + label="Baseline run", + operator="asmith", + objective="Baseline acquisition.", + sync_status=SyncStatus.SYNCED, + ), + SampleRun( + kind=RunKind.EXPERIMENTAL, + label="Repeat run", + operator="asmith", + objective="Repeat for variance.", + ), + SampleRun( + kind=RunKind.TEST, + label="Smoke test run", + operator="asmith", + objective="Test-mode dry run.", + ), + ], + ), + ], + ), + # Equipment B -- two projects. PROJ-0002 exercises config-default + custom + # README fields; PROJ-0003 carries the BLOCKED_BY_VALIDATION scenario. + SampleEquipment( + id="ALTRIG", + label="Alt Rig", + sync_mode=SyncMode.NAS, + projects=[ + SampleProject( + short_id="PROJ-0002", + name="Calibration Study", + label="Calibration Study", + operator="bjones", + objective="Calibration sweep.", + runs=[ + SampleRun( + kind=RunKind.EXPERIMENTAL, + label="Calibration A", + operator="bjones", + objective="Primary calibration.", + sync_status=SyncStatus.SYNCED, + # "sample_type" matches the seeded config default -> config_fields; + # "reviewer" matches nothing -> custom_fields. + readme_extra={"sample_type": "control", "reviewer": "asmith"}, + ), + SampleRun( + kind=RunKind.TEST, + label="Calibration dry run", + operator="bjones", + objective="Dry-run the calibration.", + ), + ], + ), + SampleProject( + short_id="PROJ-0003", + name="Failure Modes", + label="Failure Modes", + operator="bjones", + objective="Reproduce failure modes.", + runs=[ + SampleRun( + kind=RunKind.EXPERIMENTAL, + label="Bad acquisition", + operator="bjones", + objective="Trips the content scanner.", + sync_status=SyncStatus.BLOCKED_BY_VALIDATION, + files=[ + SampleFile( + relpath="data/leak.txt", + content="api_key=DEMO_SCAN_TRIGGER\n", + ) + ], + ), + SampleRun( + kind=RunKind.TEST, + label="Edge case test", + operator="bjones", + objective="Edge-case dry run.", + ), + ], + ), + ], + ), +] # the one place a developer edits diff --git a/src/exlab_wizard/tray/main.py b/src/exlab_wizard/tray/main.py index 25b0928..672c85b 100644 --- a/src/exlab_wizard/tray/main.py +++ b/src/exlab_wizard/tray/main.py @@ -227,8 +227,11 @@ def _parse_argv(argv: list[str] | None) -> argparse.Namespace: so the operator still wires that integration through Settings. Persistent across launches; ``rm -rf`` the sandbox to reset. - ``--add-test-samples`` -- only meaningful with ``--test``. - Seeds the bootstrap config with one sample equipment entry so - the wizard is runnable end-to-end without manual setup. + On first launch, seeds the full declarative sample dataset + (multiple equipment, projects, and runs, each folder carrying + production-shaped metadata) so the browse / validate / sync UIs + have realistic data. Non-destructive: a repeat boot never + re-seeds or wipes an existing sandbox. """ parser = argparse.ArgumentParser(prog="exlab-wizard-tray", add_help=True) parser.add_argument( @@ -259,8 +262,9 @@ def _parse_argv(argv: list[str] | None) -> argparse.Namespace: "--add-test-samples", action="store_true", help=( - "Only meaningful with --test: seed the starter config with one " - "sample equipment entry so the wizard is runnable end-to-end." + "Only meaningful with --test: on first launch, seed the full " + "declarative sample dataset (equipment, projects, runs) so the " + "browse / validate / sync UIs have realistic data." ), ) args = parser.parse_args(argv) @@ -269,84 +273,6 @@ def _parse_argv(argv: list[str] | None) -> argparse.Namespace: return args -def _bootstrap_test_config(config_path: Path, *, include_samples: bool) -> None: - """Write a starter test ``config.yaml`` if one does not already exist. - - Called from ``main()`` only when ``--test`` is passed. Preseeds every - path-typed field under the test sandbox so the wizard runs without - manual Settings entry; LIMS endpoint and email are intentionally left - blank so the operator still wires that integration through the live - Settings UI. With ``include_samples=True`` (``--add-test-samples``), - one minimal valid equipment entry is added so the wizard can complete - end-to-end without further setup. - - Existing test configs are never overwritten -- the sandbox is - persistent across launches and the user resets it by deleting the - suffixed directory. - """ - if config_path.exists(): - return - - # Lazy imports to keep the tray module's import graph light. These - # only land when --test is actually used. - from exlab_wizard.config.loader import save_config - from exlab_wizard.config.models import ( - Config, - EquipmentConfig, - OrchestratorConfig, - PathsConfig, - ) - from exlab_wizard.paths import ensure_dir - - sandbox = config_path.parent # e.g. ~/Library/Application Support/exlab-wizard-test - paths_cfg = PathsConfig( - templates_dir=str(sandbox / "templates"), - plugin_dir=str(sandbox / "plugins"), - local_root=str(sandbox / "local"), - ) - orchestrator_cfg = OrchestratorConfig( - label="test-workstation", - staging_root=str(sandbox / "staging"), - ) - equipment: list[EquipmentConfig] = [] - if include_samples: - equipment.append( - EquipmentConfig.model_validate( - { - "id": "TESTRIG", - "label": "Test Rig", - "local_root": str(sandbox / "local" / "TESTRIG"), - "nas_root": str(sandbox / "nas" / "TESTRIG"), - "sync_mode": "nas", - } - ) - ) - - cfg = Config( - paths=paths_cfg, - orchestrator=orchestrator_cfg, - equipment=equipment, - ) - save_config(config_path, cfg) - - # Pre-create the preseeded sub-directories so first-launch path lookups - # (template scans, plugin discovery) do not fail on a missing tree. - # ``if sub`` guards against an empty-string slipping through: ``Path("")`` - # resolves to CWD and ``mkdir`` would silently succeed there. The current - # code populates every field explicitly from ``sandbox / ``, so - # this is defensive against a future refactor of the field defaults. - for sub in ( - paths_cfg.templates_dir, - paths_cfg.plugin_dir, - paths_cfg.local_root, - orchestrator_cfg.staging_root, - ): - if sub: - ensure_dir(Path(sub)) - - _log.info("test mode: wrote starter config [path=%s]", str(config_path)) - - def _run_smoke(state_dir: Path) -> int: """Server-only loop. Boots the FastAPI server, prints the published port, waits on SIGTERM/SIGINT, then stops cleanly. @@ -409,7 +335,9 @@ def main(argv: list[str] | None = None) -> int: state_dir = ensure_state_dir() if args.test: - _bootstrap_test_config( + from exlab_wizard.config.test_bootstrap import bootstrap_test_config + + bootstrap_test_config( os_config_path(), include_samples=args.add_test_samples, ) diff --git a/tests/unit/controller/test_metadata_assembly.py b/tests/unit/controller/test_metadata_assembly.py new file mode 100644 index 0000000..0ad2240 --- /dev/null +++ b/tests/unit/controller/test_metadata_assembly.py @@ -0,0 +1,443 @@ +"""Unit + parity tests for ``controller/metadata_assembly``. + +The two helpers ``build_readme_context`` / ``build_creation_json`` were +extracted out of :class:`CreationController` so the controller and the +forthcoming sample-data seeder share one assembly layer and can never +drift (design spec §5). These tests pin the helper behaviour directly and +assert parity with the controller methods that now delegate to them. +""" + +from __future__ import annotations + +import dataclasses +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import msgspec + +from exlab_wizard.api.schemas import ( + LimsProjectBlock, + PluginApplied, +) +from exlab_wizard.cache.creation_writer import CreationWriter +from exlab_wizard.cache.equipment import EquipmentCacheWriter +from exlab_wizard.config.models import ( + Config, + EquipmentConfig, + OperatorsConfig, + PathsConfig, + READMEConfig, + READMEDefaultField, +) +from exlab_wizard.constants import ( + CREATION_JSON_VERSION, + CreationLevel, + FieldType, + LIMSProjectSource, + PluginStatus, + RunKind, + RunScope, + SyncStatus, +) +from exlab_wizard.controller.creation import ( + CreationController, + NoOpNASSync, + NoOpReadmeGenerator, + ProjectCreateRequest, + SessionStore, +) +from exlab_wizard.controller.metadata_assembly import ( + TemplateDesc, + build_creation_json, + build_readme_context, +) +from exlab_wizard.readme import CustomField +from exlab_wizard.template.copier_driver import TemplateEngine +from exlab_wizard.validator.engine import Validator + +FIXTURE_TEMPLATES = Path(__file__).parent.parent.parent / "fixtures" / "templates" +FIXTURE_PLUGINS = Path(__file__).parent.parent.parent / "fixtures" / "plugins" + +FIXED_CREATED = datetime(2026, 1, 1, 9, 0, tzinfo=UTC) +FIXED_CREATED_BY = "fixture-os-user" +FIXED_CREATED_AT_ISO = "2026-01-01T09:00:00+00:00" + + +# --------------------------------------------------------------------------- +# Fixtures / builders +# --------------------------------------------------------------------------- + + +def _config(local_root: Path, *, defaults: list[READMEDefaultField] | None = None) -> Config: + return Config( + paths=PathsConfig( + templates_dir=str(FIXTURE_TEMPLATES), + plugin_dir=str(FIXTURE_PLUGINS), + local_root=str(local_root), + ), + equipment=[ + EquipmentConfig( + id="EQ1", + label="Equipment One", + local_root=str(local_root), + nas_root="/srv/nas", + ) + ], + operators=OperatorsConfig(allowlist=[]), + readme=READMEConfig(defaults=defaults or []), + ) + + +def _template_desc() -> TemplateDesc: + return TemplateDesc( + name="seed", + version="0", + source_path="", + run_scope=RunScope.BOTH, + extra_readme_fields=[], + plugin_order=[], + ) + + +def _sample_type_default() -> READMEDefaultField: + return READMEDefaultField( + id="sample_type", + label="Sample type", + type=FieldType.CHOICE, + required=False, + default="", + options=["control", "treatment"], + ) + + +# --------------------------------------------------------------------------- +# build_readme_context — direct unit tests +# --------------------------------------------------------------------------- + + +def test_build_readme_context_partitions_readme_extra(tmp_path: Path) -> None: + """``readme_extra`` keys split across template / config / custom layers, + and core ids are skipped entirely.""" + config = _config(tmp_path, defaults=[_sample_type_default()]) + desc = dataclasses.replace( + _template_desc(), + extra_readme_fields=[{"id": "hypothesis", "type": "text", "label": "Hypothesis"}], + ) + ctx = build_readme_context( + config=config, + equipment_id="EQ1", + level=CreationLevel.PROJECT, + label="My Project", + operator="asmith", + objective="Do science.", + readme_extra={ + "hypothesis": "cells respond", # template layer + "sample_type": "control", # config layer + "reviewer": "bjones", # custom layer + "label": "ignored-core", # core id -> skipped + }, + template=desc, + short_id="PROJ-0001", + run_name=None, + run_kind_value="", + created=FIXED_CREATED, + created_by=FIXED_CREATED_BY, + ) + + assert ctx.template_fields == {"hypothesis": "cells respond"} + assert ctx.config_fields == {"sample_type": "control"} + assert ctx.custom_fields == [CustomField(label="reviewer", value="bjones")] + # The core id never leaks into custom_fields. + assert all(f.label != "label" for f in ctx.custom_fields) + + +def test_build_readme_context_system_and_level_for_run(tmp_path: Path) -> None: + config = _config(tmp_path) + ctx = build_readme_context( + config=config, + equipment_id="EQ1", + level=CreationLevel.RUN, + label="Baseline", + operator="asmith", + objective="Acquire baseline.", + readme_extra={}, + template=_template_desc(), + short_id="PROJ-0001", + run_name="Run_2026-01-01T09-00", + run_kind_value=RunKind.EXPERIMENTAL.value, + created=FIXED_CREATED, + created_by=FIXED_CREATED_BY, + ) + + assert ctx.level is CreationLevel.RUN + assert ctx.core.label == "Baseline" + assert ctx.core.operator == "asmith" + assert ctx.core.objective == "Acquire baseline." + assert ctx.system.created == FIXED_CREATED + assert ctx.system.created_by == FIXED_CREATED_BY + assert ctx.system.equipment == {"id": "EQ1", "label": "Equipment One"} + assert ctx.system.template == {"name": "seed", "version": "0"} + assert ctx.system.project == "PROJ-0001" + assert ctx.system.run == "Run_2026-01-01T09-00" + assert ctx.system.run_kind == RunKind.EXPERIMENTAL.value + + +def test_build_readme_context_unknown_equipment_blank_label(tmp_path: Path) -> None: + config = _config(tmp_path) + ctx = build_readme_context( + config=config, + equipment_id="NOPE", + level=CreationLevel.PROJECT, + label="P", + operator="o", + objective="obj", + readme_extra={}, + template=_template_desc(), + short_id="PROJ-0001", + run_name=None, + run_kind_value="", + created=FIXED_CREATED, + created_by=FIXED_CREATED_BY, + ) + assert ctx.system.equipment == {"id": "NOPE", "label": ""} + assert ctx.system.run is None + + +# --------------------------------------------------------------------------- +# build_creation_json — direct unit tests +# --------------------------------------------------------------------------- + + +def _lims_block() -> LimsProjectBlock: + return LimsProjectBlock( + uid="", + short_id="PROJ-0001", + name_at_creation="Demo Project", + source=LIMSProjectSource.LIVE, + ) + + +def test_build_creation_json_all_blocks_populated(tmp_path: Path) -> None: + config = _config(tmp_path) + dst = tmp_path / "EQ1" / "Demo Project" + plugins = [ + PluginApplied( + plugin="p", + version="1.0", + files_affected=["a.txt"], + status=PluginStatus.SUCCESS, + ), + ] + payload = build_creation_json( + config=config, + equipment_id="EQ1", + operator="asmith", + level=CreationLevel.PROJECT, + run_kind_value=RunKind.EXPERIMENTAL.value, + lims_block=_lims_block(), + template=dataclasses.replace( + _template_desc(), name="basic", version="1.2", source_path="/tpl/basic" + ), + variables={"k": "v"}, + dst=dst, + nas_root="/srv/nas", + plugins_applied=plugins, + created_at_iso=FIXED_CREATED_AT_ISO, + ) + + assert payload.schema_version == CREATION_JSON_VERSION + assert payload.created_at == FIXED_CREATED_AT_ISO + assert payload.created_by == "asmith" + assert payload.level == CreationLevel.PROJECT + assert payload.run_kind == RunKind.EXPERIMENTAL.value + assert payload.lims_project.short_id == "PROJ-0001" + assert payload.template.name == "basic" + assert payload.template.version == "1.2" + assert payload.template.source_path == "/tpl/basic" + assert payload.template.run_scope == RunScope.BOTH.value + assert payload.variables == {"k": "v"} + assert payload.paths.local == str(dst) + assert payload.paths.nas == str(Path("/srv/nas") / "EQ1") + assert payload.plugins_applied == plugins + assert payload.orchestrator is not None + assert payload.orchestrator.enabled is True + assert payload.orchestrator.equipment_label == "Equipment One" + # Default sync status. + assert payload.sync_status == SyncStatus.PENDING.value + + +def test_build_creation_json_sync_status_override(tmp_path: Path) -> None: + config = _config(tmp_path) + payload = build_creation_json( + config=config, + equipment_id="EQ1", + operator="asmith", + level=CreationLevel.RUN, + run_kind_value=RunKind.TEST.value, + lims_block=_lims_block(), + template=_template_desc(), + variables={}, + dst=tmp_path / "run", + nas_root="/srv/nas", + plugins_applied=[], + sync_status=SyncStatus.SYNCED, + created_at_iso=FIXED_CREATED_AT_ISO, + ) + assert payload.sync_status == SyncStatus.SYNCED.value + assert payload.run_kind == RunKind.TEST.value + + +def test_build_creation_json_blank_nas_when_root_empty(tmp_path: Path) -> None: + config = _config(tmp_path) + payload = build_creation_json( + config=config, + equipment_id="EQ1", + operator="asmith", + level=CreationLevel.PROJECT, + run_kind_value=RunKind.EXPERIMENTAL.value, + lims_block=_lims_block(), + template=_template_desc(), + variables={}, + dst=tmp_path / "p", + nas_root="", + plugins_applied=[], + created_at_iso=FIXED_CREATED_AT_ISO, + ) + assert payload.paths.nas == "" + + +# --------------------------------------------------------------------------- +# Parity: controller methods vs standalone helpers +# --------------------------------------------------------------------------- + + +def _controller(config: Config) -> CreationController: + return CreationController( + config=config, + validator=Validator(), + template_engine=TemplateEngine(), + plugin_host=None, + cache_creation=CreationWriter(), + cache_equipment=EquipmentCacheWriter(), + readme_generator=NoOpReadmeGenerator(), + nas_sync=NoOpNASSync(), + session_store=SessionStore(), + ) + + +def _project_request() -> ProjectCreateRequest: + return ProjectCreateRequest( + equipment_id="EQ1", + template_path=FIXTURE_TEMPLATES / "project_basic", + lims_project={ + "uid": "8c7e9d2f-1a4b-4e6c-9b3d-7f2a1e5d8c4b", + "short_id": "PROJ-0042", + "name_at_creation": "Cortex Q3 Pilot", + "source": "live", + }, + variables={"_exlab_proj": "PROJ-0042"}, + label="Cortex Q3 calibration", + operator="asmith", + objective="First-pass calibration.", + readme_extra={"sample_type": "control", "reviewer": "bjones"}, + ) + + +def _resolved_desc_from(resolved: Any) -> TemplateDesc: + return TemplateDesc( + name=resolved.name, + version=resolved.exlab_version, + source_path=str(resolved.path), + run_scope=resolved.run_scope, + extra_readme_fields=resolved.extra_readme_fields, + plugin_order=resolved.plugin_order, + ) + + +def _normalize_ctx(ctx: Any) -> Any: + """Replace the injected, environment-dependent system fields with + fixtures so two contexts assembled at different instants compare.""" + system = dataclasses.replace(ctx.system, created=FIXED_CREATED, created_by=FIXED_CREATED_BY) + return dataclasses.replace(ctx, system=system) + + +def test_parity_build_readme_context(tmp_path: Path) -> None: + local_root = tmp_path / "data" + local_root.mkdir() + config = _config(local_root, defaults=[_sample_type_default()]) + controller = _controller(config) + + req = _project_request() + resolved = controller._resolve_template(req) + dst = local_root / "EQ1" / "Cortex Q3 Pilot" + + controller_ctx = controller._build_readme_context(req=req, resolved=resolved, dst=dst) + + helper_ctx = build_readme_context( + config=config, + equipment_id=req.equipment_id, + level=CreationLevel.PROJECT, + label=req.label, + operator=req.operator, + objective=req.objective, + readme_extra=req.readme_extra, + template=_resolved_desc_from(resolved), + short_id=CreationController._short_id_for(req), + run_name=None, + run_kind_value="", + created=FIXED_CREATED, + created_by=FIXED_CREATED_BY, + ) + + assert _normalize_ctx(controller_ctx) == helper_ctx + + +async def test_parity_build_creation_json(tmp_path: Path) -> None: + local_root = tmp_path / "data" + local_root.mkdir() + config = _config(local_root, defaults=[_sample_type_default()]) + controller = _controller(config) + + req = _project_request() + resolved = controller._resolve_template(req) + dst = local_root / "EQ1" / "Cortex Q3 Pilot" + dst.mkdir(parents=True) + + from exlab_wizard.plugins.host import PluginPassResult + from exlab_wizard.template.copier_driver import RenderResult + + session = controller.session_store.open("project", req) + controller_payload = await controller._write_cache( + session=session, + req=req, + resolved=resolved, + dst=dst, + render_result=RenderResult(dst_path=dst, files_written=[]), + plugin_result=PluginPassResult(applied=[], aborted=False), + ) + + helper_payload = build_creation_json( + config=config, + equipment_id=req.equipment_id, + operator=req.operator, + level=CreationLevel.PROJECT, + run_kind_value=RunKind.EXPERIMENTAL.value, + lims_block=LimsProjectBlock( + uid=str(req.lims_project["uid"]), + short_id=str(req.lims_project["short_id"]), + name_at_creation="Cortex Q3 Pilot", + source=LIMSProjectSource.LIVE, + ), + template=_resolved_desc_from(resolved), + variables=req.variables, + dst=dst, + nas_root="/srv/nas", + plugins_applied=[], + sync_status=SyncStatus.PENDING, + created_at_iso=FIXED_CREATED_AT_ISO, + ) + + # Normalize the injected timestamp before comparing the encoded form. + norm_controller = msgspec.structs.replace(controller_payload, created_at=FIXED_CREATED_AT_ISO) + assert msgspec.json.encode(norm_controller) == msgspec.json.encode(helper_payload) diff --git a/tests/unit/dev/__init__.py b/tests/unit/dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/dev/test_seed.py b/tests/unit/dev/test_seed.py new file mode 100644 index 0000000..3be3a74 --- /dev/null +++ b/tests/unit/dev/test_seed.py @@ -0,0 +1,76 @@ +"""Tests for the standalone dev seeder (``python -m exlab_wizard.dev.seed``).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def _sandbox_under(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Pin the sandbox under ``tmp_path`` and enable test mode. + + Patches ``paths.os_config_path`` / ``paths.ensure_state_dir`` directly so + the seeder lands in ``tmp_path`` without depending on per-platform path + resolution (forcing ``sys.platform`` would make ``sysconfig`` look up a + nonexistent data module on the host). ``TEST_MODE_ENV`` is set via + ``monkeypatch.setenv`` -- so it is restored on teardown even though + ``dev.seed.main`` also sets it -- which keeps ``_app_name()`` ending in + ``-test`` for the wipe guardrail. Returns the sandbox directory. + """ + from exlab_wizard import paths + + sandbox = tmp_path / "exlab-wizard-test" + config_path = sandbox / "config.yaml" + monkeypatch.setenv(paths.TEST_MODE_ENV, "1") + monkeypatch.setattr(paths, "os_config_path", lambda: config_path) + monkeypatch.setattr(paths, "ensure_state_dir", lambda: sandbox) + return sandbox + + +def test_seed_main_creates_full_tree(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A clean sandbox (no config) is bootstrapped and fully seeded.""" + from exlab_wizard.constants import TEST_MODE_PREFIX + from exlab_wizard.dev import seed + from exlab_wizard.paths import creation_json_path + + sandbox = _sandbox_under(tmp_path, monkeypatch) + + assert seed.main([]) == 0 + + local_root = sandbox / "local" + testrig = local_root / f"{TEST_MODE_PREFIX}TESTRIG" + altrig = local_root / f"{TEST_MODE_PREFIX}ALTRIG" + assert (testrig / "Demo Project").is_dir() + assert (altrig / "Calibration Study").is_dir() + assert (altrig / "Failure Modes").is_dir() + + # 7 runs total: 4 experimental (Runs/Run_*) + 3 test (TestRuns/TestRun_*). + run_dirs = [ + *local_root.glob("*/*/Runs/Run_*"), + *local_root.glob("*/*/TestRuns/TestRun_*"), + ] + assert len(run_dirs) == 7 + for run_dir in run_dirs: + assert (run_dir / "README.md").is_file() + assert creation_json_path(run_dir).is_file() + + +def test_seed_main_wipes_and_rebuilds(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A second run wipes the seeded subtree (clearing stray files) and rebuilds.""" + from exlab_wizard.constants import TEST_MODE_PREFIX + from exlab_wizard.dev import seed + + sandbox = _sandbox_under(tmp_path, monkeypatch) + + assert seed.main([]) == 0 + + # A stray file inside a seeded equipment dir must not survive the wipe. + local_root = sandbox / "local" + stray = local_root / f"{TEST_MODE_PREFIX}TESTRIG" / "STRAY.txt" + stray.write_text("x", encoding="utf-8") + assert stray.exists() + + assert seed.main([]) == 0 + assert not stray.exists() + assert (local_root / f"{TEST_MODE_PREFIX}TESTRIG" / "Demo Project").is_dir() diff --git a/tests/unit/sample_data/__init__.py b/tests/unit/sample_data/__init__.py new file mode 100644 index 0000000..27a72e2 --- /dev/null +++ b/tests/unit/sample_data/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the declarative sample-data spec and generator (Phase 2+).""" diff --git a/tests/unit/sample_data/test_generator.py b/tests/unit/sample_data/test_generator.py new file mode 100644 index 0000000..00a401d --- /dev/null +++ b/tests/unit/sample_data/test_generator.py @@ -0,0 +1,492 @@ +"""End-to-end tests for the sample-data generator (design spec §6/§7/§9). + +These drive ``generate_samples`` against a temporary ``-test`` sandbox and +assert the full on-disk tree is written through the real producers: every +project/run folder carries a valid ``README.md`` + ``creation.json`` + +``readme_fields.json`` (decoded with the production msgspec Structs and the +README front-matter), each equipment root carries ``equipment.json``, and +test-run projects carry the ``test_runs.json`` marker. They also cover the +sync-status spread, payload files, README field layering, determinism, and +the destructive-wipe guardrails. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +import msgspec +import pytest +import yaml + +from exlab_wizard.api.schemas import CreationJson, EquipmentJson, ReadmeFieldsJson +from exlab_wizard.api.schemas import ( + TestRunsJson as _TestRunsJson, # aliased: avoid pytest collection +) +from exlab_wizard.config.loader import load_config, save_config +from exlab_wizard.config.models import Config, OrchestratorConfig, PathsConfig +from exlab_wizard.constants import TEST_MODE_ENV +from exlab_wizard.constants.enums import SyncStatus +from exlab_wizard.paths import ( + cache_dir, + creation_json_path, + equipment_json_path, + readme_fields_json_path, +) +from exlab_wizard.sample_data.generator import SampleDataGenerator, generate_samples +from exlab_wizard.sample_data.spec import SAMPLES + +BASE_TIME = datetime(2026, 1, 1, 9, 0, tzinfo=UTC) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sandbox(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Build a ``-test`` sandbox with a starter config; return ``config_path``. + + Sets ``EXLAB_WIZARD_TEST_MODE=1`` (so the loader stamps the ``TEST_`` + prefix and the wipe guardrail is satisfied) and points the OS path + helpers' ``_app_name`` at an ``...-test`` name. + """ + monkeypatch.setenv(TEST_MODE_ENV, "1") + config_path = tmp_path / "config.yaml" + starter = Config( + paths=PathsConfig( + templates_dir=str(tmp_path / "templates"), + plugin_dir=str(tmp_path / "plugins"), + local_root=str(tmp_path / "local"), + ), + orchestrator=OrchestratorConfig(label="test-workstation"), + ) + save_config(config_path, starter) + return config_path + + +def _read_creation(folder: Path) -> CreationJson: + raw = creation_json_path(folder).read_bytes() + return msgspec.json.decode(raw, type=CreationJson) + + +def _read_readme_fields(folder: Path) -> ReadmeFieldsJson: + raw = readme_fields_json_path(folder).read_bytes() + return msgspec.json.decode(raw, type=ReadmeFieldsJson) + + +def _read_front_matter(folder: Path) -> dict: + text = (folder / "README.md").read_text(encoding="utf-8") + assert text.startswith("---\n") + _, fm, _ = text.split("---\n", 2) + return yaml.safe_load(fm) + + +def _read_label(folder: Path) -> str: + """Return the README ``core_fields.label`` for a project/run folder.""" + return _read_front_matter(folder)["core_fields"]["label"] + + +def _project_dirs(local_root: Path) -> list[Path]: + """Return every ``/`` directory under ``local_root``. + + Excludes the ``.exlab-wizard`` cache dir at the equipment root (which + holds ``equipment.json``) -- it is not a project. + """ + out: list[Path] = [] + for eq_dir in local_root.iterdir(): + if not eq_dir.is_dir(): + continue + for proj_dir in eq_dir.iterdir(): + if proj_dir.is_dir() and proj_dir.name != ".exlab-wizard": + out.append(proj_dir) + return out + + +def _run_dirs(project_dir: Path) -> list[Path]: + runs: list[Path] = [] + for kind_dir_name in ("Runs", "TestRuns"): + kind_dir = project_dir / kind_dir_name + if kind_dir.is_dir(): + runs.extend(p for p in kind_dir.iterdir() if p.is_dir()) + return runs + + +# --------------------------------------------------------------------------- +# Tree existence +# --------------------------------------------------------------------------- + + +def test_full_tree_exists(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + # Prefixed equipment roots. + roots = {p.name for p in local_root.iterdir() if p.is_dir()} + assert roots == {"TEST_TESTRIG", "TEST_ALTRIG"} + + # 3 project dirs. + project_dirs = _project_dirs(local_root) + assert len(project_dirs) == 3 + + # 7 run dirs total, with the right prefixes per kind. + all_runs = [r for pd in project_dirs for r in _run_dirs(pd)] + assert len(all_runs) == 7 + for run in all_runs: + parent = run.parent.name + if parent == "Runs": + assert run.name.startswith("Run_") + else: + assert parent == "TestRuns" + assert run.name.startswith("TestRun_") + + +def test_every_folder_has_metadata(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + project_dirs = _project_dirs(local_root) + folders = list(project_dirs) + for pd in project_dirs: + folders.extend(_run_dirs(pd)) + + for folder in folders: + assert (folder / "README.md").is_file(), folder + # Decode with the real Structs -> raises on malformed metadata. + creation = _read_creation(folder) + assert creation.schema_version + fields = _read_readme_fields(folder) + assert fields.schema_version + fm = _read_front_matter(folder) + assert isinstance(fm, dict) and fm + + # equipment.json at each equipment root. + for eq_dir in local_root.iterdir(): + if not eq_dir.is_dir(): + continue + raw = equipment_json_path(eq_dir).read_bytes() + eq = msgspec.json.decode(raw, type=EquipmentJson) + assert eq.id == eq_dir.name + assert eq.configured_local_root == str(local_root) + + +def test_test_runs_marker_present_for_test_projects(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + for project_dir in _project_dirs(local_root): + has_test_run = (project_dir / "TestRuns").is_dir() + marker = cache_dir(project_dir) / "test_runs.json" + if has_test_run: + assert marker.is_file(), project_dir + payload = msgspec.json.decode(marker.read_bytes(), type=_TestRunsJson) + assert payload.equipment == project_dir.parent.name + assert payload.run_kind.value == "test" + else: + assert not marker.exists(), project_dir + + +# --------------------------------------------------------------------------- +# Sync-status spread +# --------------------------------------------------------------------------- + + +def test_sync_status_spread(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + seen: set[SyncStatus] = set() + for project_dir in _project_dirs(local_root): + for run in _run_dirs(project_dir): + seen.add(_read_creation(run).sync_status) + + # The seeded runs collectively cover these three badge states. + assert { + SyncStatus.SYNCED, + SyncStatus.PENDING, + SyncStatus.BLOCKED_BY_VALIDATION, + } <= seen + + +def test_each_run_sync_status_matches_sample(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + # Build expected: {run label -> sync_status} from SAMPLES. + expected: dict[str, SyncStatus] = {} + for eq in SAMPLES: + for proj in eq.projects: + for run in proj.runs: + expected[run.label] = run.sync_status + + for project_dir in _project_dirs(local_root): + for run_dir in _run_dirs(project_dir): + creation = _read_creation(run_dir) + label = _read_label(run_dir) + assert creation.sync_status == expected[label], label + + +# --------------------------------------------------------------------------- +# Payload files +# --------------------------------------------------------------------------- + + +def test_default_payload_pair(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + # The "Repeat run" (PROJ-0001) has files=None -> default pair. + repeat_run = None + for project_dir in _project_dirs(local_root): + for run_dir in _run_dirs(project_dir): + if _read_label(run_dir) == "Repeat run": + repeat_run = run_dir + assert repeat_run is not None + assert (repeat_run / "data" / "acq_001.csv").is_file() + assert (repeat_run / "notes.txt").is_file() + + +def test_blocked_run_carries_trigger_file(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + blocked = None + for project_dir in _project_dirs(local_root): + if project_dir.name != "Failure Modes": + continue + for run_dir in _run_dirs(project_dir): + if _read_creation(run_dir).sync_status is SyncStatus.BLOCKED_BY_VALIDATION: + blocked = run_dir + assert blocked is not None + trigger = blocked / "data" / "leak.txt" + assert trigger.is_file() + assert "DEMO_SCAN_TRIGGER" in trigger.read_text(encoding="utf-8") + + # The seeded extensions fall within the validator's content_scan set. + scan_exts = set(config.validator.content_scan_extensions) + assert ".txt" in scan_exts + assert ".csv" in scan_exts + + +# --------------------------------------------------------------------------- +# README layering +# --------------------------------------------------------------------------- + + +def test_readme_field_layering(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + # Find the "Calibration A" run under "Calibration Study". + cal_a = None + for project_dir in _project_dirs(local_root): + if project_dir.name != "Calibration Study": + continue + for run_dir in _run_dirs(project_dir): + if _read_label(run_dir) == "Calibration A": + cal_a = run_dir + assert cal_a is not None + + fields = _read_readme_fields(cal_a) + # "sample_type" matches the seeded config default -> config_fields. + assert fields.config_fields.get("sample_type") == "control" + # "reviewer" matches nothing -> custom_fields. + custom_labels = {c["label"]: c["value"] for c in fields.custom_fields} + assert custom_labels.get("reviewer") == "asmith" + + +def test_seeded_config_default_is_present_and_optional(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + + defaults = {d.id: d for d in config.readme.defaults} + assert "sample_type" in defaults + sample_type = defaults["sample_type"] + assert sample_type.required is False + assert sample_type.options == ["control", "treatment"] + # operators allowlist stays empty. + assert config.operators.allowlist == [] + + +# --------------------------------------------------------------------------- +# Determinism +# --------------------------------------------------------------------------- + + +def _snapshot(local_root: Path) -> dict[str, bytes]: + """Map relpath -> bytes for README/creation.json/payload (no equipment.json).""" + snap: dict[str, bytes] = {} + for path in sorted(local_root.rglob("*")): + if not path.is_file(): + continue + rel = str(path.relative_to(local_root)) + if path.name == "equipment.json": + # Writer-stamped timestamps are non-deterministic by design. + continue + snap[rel] = path.read_bytes() + return snap + + +def test_determinism(sandbox: Path) -> None: + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + first = _snapshot(local_root) + first_paths = sorted(first) + + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + second = _snapshot(local_root) + second_paths = sorted(second) + + assert first_paths == second_paths + assert first == second + + +def test_equipment_json_normalized_is_deterministic(sandbox: Path) -> None: + """equipment.json differs only in the two writer-stamped timestamps.""" + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + def _norm(root: Path) -> dict[str, dict]: + out: dict[str, dict] = {} + for eq_dir in root.iterdir(): + if not eq_dir.is_dir(): + continue + eq = msgspec.json.decode(equipment_json_path(eq_dir).read_bytes(), type=EquipmentJson) + d = msgspec.to_builtins(eq) + d.pop("first_seen_at") + d.pop("last_modified_at") + out[eq_dir.name] = d + return out + + first = _norm(local_root) + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + second = _norm(local_root) + assert first == second + + +# --------------------------------------------------------------------------- +# Guardrails +# --------------------------------------------------------------------------- + + +def test_wipe_refuses_when_test_mode_unset(sandbox: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(TEST_MODE_ENV, raising=False) + with pytest.raises(RuntimeError): + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + + +def test_wipe_refuses_when_app_name_not_test( + sandbox: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import exlab_wizard.sample_data.generator as gen + + monkeypatch.setattr(gen, "_app_name", lambda: "exlab-wizard") + with pytest.raises(RuntimeError): + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + + +def test_wipe_refuses_when_target_escapes_sandbox( + sandbox: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # First seed without wipe to create config; then force the local_root to + # point outside the sandbox so the containment check fails. + generate_samples(sandbox, wipe=False, base_time=BASE_TIME) + config = load_config(sandbox) + escaped = Config( + paths=PathsConfig( + templates_dir=config.paths.templates_dir, + plugin_dir=config.paths.plugin_dir, + local_root="/tmp", + ), + orchestrator=config.orchestrator, + ) + save_config(sandbox, escaped) + with pytest.raises(RuntimeError, match="strict subpath"): + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + + +def test_wipe_refuses_symlinked_target_escaping_sandbox(sandbox: Path, tmp_path: Path) -> None: + """A seeded equipment dir replaced by a symlink out of the sandbox is refused.""" + import shutil + + generate_samples(sandbox, wipe=False, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + + # A directory OUTSIDE the sandbox (a sibling of the sandbox dir). + outside = tmp_path.parent / f"escape-{tmp_path.name}" + outside.mkdir() + (outside / "precious.txt").write_text("do not delete", encoding="utf-8") + try: + victim = local_root / "TEST_TESTRIG" + shutil.rmtree(victim) + victim.symlink_to(outside, target_is_directory=True) + with pytest.raises(RuntimeError, match="strict subpath"): + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + # The wipe resolved the symlink, saw it escape the sandbox, and refused + # before touching anything -- the external dir is untouched. + assert (outside / "precious.txt").is_file() + finally: + shutil.rmtree(outside, ignore_errors=True) + + +def test_seeded_equipment_roots_are_base_paths(sandbox: Path) -> None: + """Seeded ``EquipmentConfig.local_root``/``nas_root`` are BASE roots. + + Consumers (orchestrator quiescence poller, validator) compose + ``Path(equipment.local_root) / equipment.id`` and ``build_creation_json`` + composes ``Path(nas_root) / equipment_id`` -- so the seeded roots must be the + base (no id), matching a real operator config, or the seeded tree is + invisible to run-walking. + """ + generate_samples(sandbox, wipe=True, base_time=BASE_TIME) + config = load_config(sandbox) + sandbox_dir = sandbox.parent + + for entry in config.equipment: + assert entry.local_root == config.paths.local_root == str(sandbox_dir / "local") + assert entry.nas_root == str(sandbox_dir / "nas") + + # creation.json ``paths.nas`` = base nas_root + prefixed id; ``paths.local`` + # is the run dir itself. + local_root = Path(config.paths.local_root) + for project_dir in _project_dirs(local_root): + for run_dir in _run_dirs(project_dir): + creation = _read_creation(run_dir) + eq_id = run_dir.parents[2].name + assert creation.paths.nas == str(sandbox_dir / "nas" / eq_id) + assert creation.paths.local == str(run_dir) + + +def test_no_wipe_is_idempotent_and_nondestructive(sandbox: Path) -> None: + """``wipe=False`` seeds, and a re-seed without wipe does not delete the tree.""" + generate_samples(sandbox, wipe=False, base_time=BASE_TIME) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + assert (local_root / "TEST_TESTRIG").is_dir() + + # Drop a sentinel file an operator might have added between runs. + sentinel = local_root / "TEST_TESTRIG" / "operator_added.txt" + sentinel.write_text("keep me", encoding="utf-8") + + generate_samples(sandbox, wipe=False, base_time=BASE_TIME) + assert sentinel.is_file(), "wipe=False must never delete an existing tree" + + +def test_generator_class_is_constructable(sandbox: Path) -> None: + gen = SampleDataGenerator(config_path=sandbox, base_time=BASE_TIME) + gen.generate(wipe=True) + config = load_config(sandbox) + local_root = Path(config.paths.local_root) + assert (local_root / "TEST_TESTRIG").is_dir() diff --git a/tests/unit/sample_data/test_spec.py b/tests/unit/sample_data/test_spec.py new file mode 100644 index 0000000..5881629 --- /dev/null +++ b/tests/unit/sample_data/test_spec.py @@ -0,0 +1,167 @@ +"""Tests for the declarative ``SAMPLES`` list and its Pydantic models (§4). + +These guard the spec data's shape (2 equipment / 3 projects / 7 runs spanning +the sync-status badge states) and the fail-fast field validators that reject +malformed ids / names at construction time. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from exlab_wizard.constants.enums import RunKind, SyncMode, SyncStatus +from exlab_wizard.sample_data.spec import ( + SAMPLES, + SampleEquipment, + SampleFile, + SampleProject, + SampleRun, +) + + +def _project(equipment: SampleEquipment, short_id: str) -> SampleProject: + return next(p for p in equipment.projects if p.short_id == short_id) + + +def test_samples_top_level_shape() -> None: + """Two equipment entries, keyed TESTRIG then ALTRIG.""" + assert len(SAMPLES) == 2 + assert [e.id for e in SAMPLES] == ["TESTRIG", "ALTRIG"] + assert all(e.sync_mode is SyncMode.NAS for e in SAMPLES) + + +def test_testrig_has_one_project_three_runs() -> None: + testrig = SAMPLES[0] + assert testrig.id == "TESTRIG" + assert [p.short_id for p in testrig.projects] == ["PROJ-0001"] + assert len(testrig.projects[0].runs) == 3 + + +def test_altrig_has_two_projects_two_runs_each() -> None: + altrig = SAMPLES[1] + assert altrig.id == "ALTRIG" + assert [p.short_id for p in altrig.projects] == ["PROJ-0002", "PROJ-0003"] + assert len(_project(altrig, "PROJ-0002").runs) == 2 + assert len(_project(altrig, "PROJ-0003").runs) == 2 + + +def test_total_run_count_is_seven() -> None: + total = sum(len(p.runs) for e in SAMPLES for p in e.projects) + assert total == 7 + + +def test_malformed_short_id_raises_validation_error() -> None: + with pytest.raises(ValidationError): + SampleProject( + short_id="proj-0001", # lower-case -> fails PROJECT_SHORT_ID_PATTERN + name="Demo Project", + label="Demo Project", + operator="asmith", + objective="x", + runs=[ + SampleRun( + kind=RunKind.EXPERIMENTAL, + label="r", + operator="asmith", + objective="x", + ) + ], + ) + + +def test_malformed_equipment_id_raises_validation_error() -> None: + with pytest.raises(ValidationError): + SampleEquipment( + id="test_rig", # lower-case / underscore-led -> fails equipment grammar + label="Test Rig", + projects=[ + SampleProject( + short_id="PROJ-0001", + name="Demo Project", + label="Demo Project", + operator="asmith", + objective="x", + runs=[ + SampleRun( + kind=RunKind.EXPERIMENTAL, + label="r", + operator="asmith", + objective="x", + ) + ], + ) + ], + ) + + +def test_malformed_project_name_raises_validation_error() -> None: + # A path separator survives str-stripping and is rejected by + # validate_project_name (illegal filesystem character). + with pytest.raises(ValidationError): + SampleProject( + short_id="PROJ-0001", + name="bad/name", + label="bad/name", + operator="asmith", + objective="x", + runs=[ + SampleRun( + kind=RunKind.EXPERIMENTAL, + label="r", + operator="asmith", + objective="x", + ) + ], + ) + + +def test_unknown_key_rejected_extra_forbid() -> None: + with pytest.raises(ValidationError): + SampleRun( + kind=RunKind.EXPERIMENTAL, + label="r", + operator="asmith", + objective="x", + bogus="nope", # type: ignore[call-arg] + ) + + +@pytest.mark.parametrize("bad", ["/abs/data.txt", "../escape.txt", "data/../../escape.txt"]) +def test_samplefile_rejects_escaping_relpath(bad: str) -> None: + """``relpath`` must stay under the run dir -- no absolute or ``..`` paths.""" + with pytest.raises(ValidationError): + SampleFile(relpath=bad) + + +def test_samplefile_accepts_nested_relative_path() -> None: + assert SampleFile(relpath="data/sub/acq_001.csv").relpath == "data/sub/acq_001.csv" + + +def test_sync_status_spread_covers_required_states() -> None: + statuses = {r.sync_status for e in SAMPLES for p in e.projects for r in p.runs} + required = { + SyncStatus.SYNCED, + SyncStatus.PENDING, + SyncStatus.BLOCKED_BY_VALIDATION, + } + assert required <= statuses + + +def test_proj_0003_blocked_run_carries_trigger_file() -> None: + altrig = SAMPLES[1] + proj = _project(altrig, "PROJ-0003") + blocked = next(r for r in proj.runs if r.sync_status is SyncStatus.BLOCKED_BY_VALIDATION) + assert blocked.files is not None + assert [f.relpath for f in blocked.files] == ["data/leak.txt"] + trigger = blocked.files[0] + assert isinstance(trigger, SampleFile) + assert "DEMO_SCAN_TRIGGER" in trigger.content + + +def test_proj_0002_calibration_a_has_readme_extra_fields() -> None: + altrig = SAMPLES[1] + proj = _project(altrig, "PROJ-0002") + cal_a = next(r for r in proj.runs if r.label == "Calibration A") + assert cal_a.readme_extra.get("sample_type") == "control" + assert cal_a.readme_extra.get("reviewer") == "asmith" diff --git a/tests/unit/tray/test_main.py b/tests/unit/tray/test_main.py index 6e8eb67..ca5d9e7 100644 --- a/tests/unit/tray/test_main.py +++ b/tests/unit/tray/test_main.py @@ -543,13 +543,37 @@ def test_main_test_with_samples_adds_equipment(test_mode_env: Path) -> None: tray_main.main(["--test", "--add-test-samples"]) - cfg = load_config(test_mode_env / ".config" / "exlab-wizard-test" / "config.yaml") - assert len(cfg.equipment) == 1 - # ``--test`` sets EXLAB_WIZARD_TEST_MODE=1, which makes the loader - # rewrite every equipment id with the ``TEST_`` prefix so test-mode - # runs land under an identifiable namespace on the NAS. The seeded - # ``TESTRIG`` sample therefore surfaces as ``TEST_TESTRIG``. - assert cfg.equipment[0].id == f"{TEST_MODE_PREFIX}TESTRIG" + sandbox = test_mode_env / ".config" / "exlab-wizard-test" + cfg = load_config(sandbox / "config.yaml") + # ``--add-test-samples`` now seeds the declarative ``SAMPLES`` set (two + # equipment) via the shared generator instead of one hardcoded entry. + # ``--test`` sets EXLAB_WIZARD_TEST_MODE=1, so the loader rewrites every + # equipment id with the ``TEST_`` prefix (an identifiable NAS namespace). + assert {e.id for e in cfg.equipment} == { + f"{TEST_MODE_PREFIX}TESTRIG", + f"{TEST_MODE_PREFIX}ALTRIG", + } + # A full sample tree was seeded on disk under the sandbox local root. + local_root = sandbox / "local" + assert (local_root / f"{TEST_MODE_PREFIX}TESTRIG" / "Demo Project").is_dir() + assert (local_root / f"{TEST_MODE_PREFIX}ALTRIG" / "Failure Modes").is_dir() + + +def test_main_test_with_samples_repeat_boot_is_noop(test_mode_env: Path) -> None: + """A second ``--test --add-test-samples`` boot must neither re-seed nor wipe.""" + from exlab_wizard.constants import TEST_MODE_PREFIX + from exlab_wizard.tray import main as tray_main + + tray_main.main(["--test", "--add-test-samples"]) + + sandbox = test_mode_env / ".config" / "exlab-wizard-test" + operator_file = sandbox / "local" / f"{TEST_MODE_PREFIX}TESTRIG" / "operator_added.txt" + operator_file.write_text("keep me", encoding="utf-8") + + # bootstrap_test_config short-circuits on the existing config, so the second + # boot never calls generate_samples -- no wipe, no re-seed. + tray_main.main(["--test", "--add-test-samples"]) + assert operator_file.is_file(), "repeat boot must not wipe/re-seed an existing sandbox" def test_main_without_test_does_not_set_env(test_mode_env: Path) -> None: