Source code for exlab_wizard.lims.catalogue

"""Offline LIMS project catalogue read/write. Backend Spec §7.2.9.

A catalogue is a single JSON document at a NAS-shared path that lets a
disconnected workstation populate its LIMS-project picker without
reaching the live LIMS API. The producer workstation writes the file
on every successful LIMS refresh; the consumer workstation reads it
when its local SQLite cache is empty AND the LIMS API is unreachable
(see §7.2.9.3 for the consumer trigger).

This module provides only the file-format I/O. The producer-vs-consumer
trigger logic, the warning-and-fall-through behavior on parse errors,
and the picker-badge annotation are integrated by the caller (typically
the LIMSClient or its supervising controller).

File format (§7.2.9.1):

```json
{
  "schema_version": "1.0",
  "produced_by": "LAB_STATION_01",
  "produced_at": "2026-05-05T14:23:00Z",
  "lims_endpoint": "https://lims.lab.example/api/v1",
  "projects": [ {LIMSProject row}, ... ]
}
```

Atomic write (§7.2.9.2): write to ``<path>.tmp.<pid>``, fsync, then
``os.replace`` to the final path. Concurrent producers are benign --
each rename is atomic; the last writer wins.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import msgspec

from exlab_wizard.constants import OFFLINE_CATALOGUE_VERSION
from exlab_wizard.errors import ConfigError
from exlab_wizard.io import atomic_write_bytes, read_msgspec_json_raw
from exlab_wizard.lims.schemas import LIMSProject
from exlab_wizard.logging import get_logger

__all__ = ["OfflineCatalogue", "read_catalogue", "write_catalogue"]

logger = get_logger(__name__)


[docs] @dataclass class OfflineCatalogue: """Decoded offline catalogue. Backend Spec §7.2.9.1. ``schema_version`` is pinned to the constant declared in :mod:`exlab_wizard.constants.schema_versions`; mismatches surface as :class:`exlab_wizard.errors.ConfigError`. ``lims_endpoint`` is verified by :func:`read_catalogue` against the consumer's configured LIMS endpoint; mismatches are rejected per §7.2.9.3 to defend against accidentally pointing at a different lab's LIMS. """ schema_version: str produced_by: str produced_at: str lims_endpoint: str projects: list[LIMSProject]
[docs] def read_catalogue(path: Path, *, expected_endpoint: str) -> OfflineCatalogue: """Read and validate the catalogue file. Raises :class:`exlab_wizard.errors.ConfigError` on any of: - file missing / unreadable - JSON parse error - ``schema_version`` is not the constant :data:`exlab_wizard.constants.OFFLINE_CATALOGUE_VERSION` - ``lims_endpoint`` differs from ``expected_endpoint`` (per §7.2.9.3 the producer's LIMS must match the consumer's configuration; cross-lab leakage is rejected, not warned). """ try: decoded = read_msgspec_json_raw(Path(path)) except OSError as exc: msg = f"offline catalogue not readable at {path}: {exc}" raise ConfigError(msg) from exc except msgspec.DecodeError as exc: msg = f"offline catalogue at {path} is not valid JSON: {exc}" raise ConfigError(msg) from exc # TODO(spec): the OFFLINE_CATALOGUE_VERSION check below is an # exact-match (major+minor); this is intentionally stricter than the # §11.9.2 major-only gate used for cache files. Revisit once the spec # clarifies whether offline catalogues should follow the same policy. schema_version = decoded.get("schema_version") if schema_version != OFFLINE_CATALOGUE_VERSION: msg = ( f"offline catalogue at {path} has schema_version " f"{schema_version!r}; expected {OFFLINE_CATALOGUE_VERSION!r}" ) raise ConfigError(msg) lims_endpoint = decoded.get("lims_endpoint", "") if lims_endpoint != expected_endpoint: msg = ( f"offline catalogue at {path} describes LIMS endpoint " f"{lims_endpoint!r}; expected {expected_endpoint!r}" ) raise ConfigError(msg) project_rows = decoded.get("projects") or [] projects = [msgspec.convert(row, LIMSProject) for row in project_rows] return OfflineCatalogue( schema_version=schema_version, produced_by=decoded.get("produced_by", ""), produced_at=decoded.get("produced_at", ""), lims_endpoint=lims_endpoint, projects=projects, )
[docs] def write_catalogue(path: Path, catalogue: OfflineCatalogue) -> None: """Atomically write ``catalogue`` to ``path``. Backend Spec §7.2.9.2. Protocol: serialize, write to ``<path>.tmp.<pid>``, fsync, then ``os.replace`` to the final path. Concurrent producers do not corrupt the file -- each rename is atomic; the last writer wins. """ target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) payload = { "schema_version": catalogue.schema_version, "produced_by": catalogue.produced_by, "produced_at": catalogue.produced_at, "lims_endpoint": catalogue.lims_endpoint, "projects": [_project_to_dict(p) for p in catalogue.projects], } encoded = msgspec.json.encode(payload) atomic_write_bytes(target, encoded)
def _project_to_dict(project: LIMSProject) -> dict: """Re-emit a LIMSProject as a serializable dict.""" return { "uid": project.uid, "short_id": project.short_id, "name": project.name, "description": project.description, "status": project.status, "contact_name": project.contact_name, "owner": project.owner, "metadata": project.metadata, "fetched_at": project.fetched_at, }