Source code for exlab_wizard.api.routers.browse

"""``/tree`` and ``/run/{path}`` browse endpoints. Backend Spec §4.6.1.

Two endpoints back the Frontend's tree view (Frontend §3.6) and run
detail panel (Frontend §3.6.2):

* ``GET /tree`` -- equipment / project / run hierarchy.
* ``GET /run/{path}`` -- single-run detail (template, operator, sync
  status, run kind, README content).

The router walks the local filesystem under each configured equipment
root via ``os.scandir`` (the same iterator the validator uses for
audit-mode walks; §4.5). ``creation.json`` is decoded via
``msgspec.json.decode`` per §4.4.5.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import Any

import msgspec
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict

from exlab_wizard.api._dependencies import require_deps
from exlab_wizard.api.schemas import CreationJson
from exlab_wizard.api.setup import setup_state_gate
from exlab_wizard.constants import (
    CACHE_DIR_NAME,
    README_FILE_NAME,
    TEST_RUNS_DIR_NAME,
)
from exlab_wizard.io import read_msgspec_json
from exlab_wizard.logging import get_logger
from exlab_wizard.paths import creation_json_path, is_run_dir, is_test_run_dir

__all__ = [
    "EquipmentNode",
    "ProjectNode",
    "RunDetail",
    "RunNode",
    "TreeResponse",
    "build_browse_router",
]

_log = get_logger(__name__)


# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------


[docs] class RunNode(BaseModel): model_config = ConfigDict(extra="forbid") name: str path: str kind: str # "experimental" | "test" sync_status: str | None = None has_creation_json: bool = False
[docs] class ProjectNode(BaseModel): model_config = ConfigDict(extra="forbid") short_id: str path: str runs: list[RunNode] = [] test_runs: list[RunNode] = [] has_creation_json: bool = False
[docs] class EquipmentNode(BaseModel): model_config = ConfigDict(extra="forbid") id: str label: str path: str projects: list[ProjectNode] = []
[docs] class TreeResponse(BaseModel): model_config = ConfigDict(extra="forbid") equipment: list[EquipmentNode]
[docs] class RunDetail(BaseModel): model_config = ConfigDict(extra="forbid") path: str schema_version: str | None = None template: dict[str, Any] | None = None operator: str | None = None label: str | None = None run_kind: str | None = None sync_status: str | None = None readme: str | None = None plugins_applied: list[dict[str, Any]] = [] validation_overrides: list[dict[str, Any]] = []
# --------------------------------------------------------------------------- # Router builder # ---------------------------------------------------------------------------
[docs] def build_browse_router() -> APIRouter: """Construct the ``/tree`` + ``/run`` router.""" router = APIRouter(tags=["browse"]) @router.get( "/tree", response_model=TreeResponse, dependencies=[Depends(setup_state_gate)], ) async def get_tree(request: Request) -> TreeResponse: deps = require_deps(request) config = getattr(deps, "config", None) if config is None: return TreeResponse(equipment=[]) nodes = [ _build_equipment_node(entry, Path(config.paths.local_root)) for entry in config.equipment ] return TreeResponse(equipment=nodes) @router.get( "/run/{run_path:path}", response_model=RunDetail, dependencies=[Depends(setup_state_gate)], ) async def get_run(run_path: str) -> RunDetail: path = Path(run_path) cache_path = creation_json_path(path) if not cache_path.exists(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={ "code": "session_not_found", "message": f"creation.json not found at {cache_path}", }, ) try: payload = read_msgspec_json(cache_path, CreationJson) except (msgspec.DecodeError, msgspec.ValidationError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={ "code": "validation_failed", "message": str(exc), }, ) from exc readme_text = _read_readme(path) return RunDetail( path=str(path), schema_version=payload.schema_version, template={ "name": payload.template.name, "version": payload.template.version, "source_path": payload.template.source_path, "run_scope": payload.template.run_scope, }, operator=payload.created_by, label=payload.lims_project.name_at_creation, run_kind=payload.run_kind, sync_status=payload.sync_status, readme=readme_text, plugins_applied=[msgspec.to_builtins(applied) for applied in payload.plugins_applied], validation_overrides=list(payload.validation_overrides), ) return router
# --------------------------------------------------------------------------- # Tree builders # --------------------------------------------------------------------------- def _build_equipment_node(entry: Any, local_root: Path) -> EquipmentNode: equipment_dir = local_root / entry.id projects = _scan_projects(equipment_dir) if equipment_dir.exists() else [] return EquipmentNode( id=entry.id, label=entry.label or entry.id, path=str(equipment_dir), projects=projects, ) def _iter_run_or_project_subdirs(parent: Path) -> list[os.DirEntry[str]]: """Return name-sorted real subdirectories of ``parent``, sans the cache. Hides the ``.exlab-wizard/`` cache directory and silently swallows ``FileNotFoundError`` / ``PermissionError`` so callers can iterate without per-call ``try``/``except`` blocks. The returned list is sorted lexicographically by entry name so the on-wire tree order is stable across runs. """ try: entries = list(os.scandir(parent)) except (FileNotFoundError, PermissionError): return [] out: list[os.DirEntry[str]] = [] for entry in sorted(entries, key=lambda e: e.name): if not entry.is_dir(follow_symlinks=False): continue if entry.name == CACHE_DIR_NAME: continue out.append(entry) return out def _scan_projects(equipment_dir: Path) -> list[ProjectNode]: """Return a sorted list of project nodes under an equipment dir.""" return [ _build_project_node(Path(entry.path)) for entry in _iter_run_or_project_subdirs(equipment_dir) ] def _build_project_node(project_dir: Path) -> ProjectNode: runs: list[RunNode] = [] test_runs: list[RunNode] = [] for entry in _iter_run_or_project_subdirs(project_dir): if entry.name == TEST_RUNS_DIR_NAME: test_runs.extend(_scan_run_children(Path(entry.path), kind="test")) continue if is_run_dir(entry.name): runs.append(_build_run_node(Path(entry.path), kind="experimental")) has_cache = creation_json_path(project_dir).exists() return ProjectNode( short_id=project_dir.name, path=str(project_dir), runs=runs, test_runs=test_runs, has_creation_json=has_cache, ) def _scan_run_children(test_runs_dir: Path, *, kind: str) -> list[RunNode]: return [ _build_run_node(Path(entry.path), kind=kind) for entry in _iter_run_or_project_subdirs(test_runs_dir) if is_test_run_dir(entry.name) ] def _build_run_node(run_dir: Path, *, kind: str) -> RunNode: cache_path = creation_json_path(run_dir) sync_status: str | None = None has_cache = cache_path.exists() if has_cache: try: payload = read_msgspec_json(cache_path, CreationJson) sync_status = payload.sync_status except (msgspec.DecodeError, msgspec.ValidationError): sync_status = None return RunNode( name=run_dir.name, path=str(run_dir), kind=kind, sync_status=sync_status, has_creation_json=has_cache, ) # --------------------------------------------------------------------------- # Run-detail helpers # --------------------------------------------------------------------------- def _read_readme(run_dir: Path) -> str | None: """Return the run's README.md text, or ``None`` if absent / unreadable.""" readme_path = run_dir / README_FILE_NAME if not readme_path.exists(): return None try: return readme_path.read_text(encoding="utf-8") except OSError: return None