Source code for exlab_wizard.io.json_files
"""msgspec JSON readers with optional schema-major gating.
Centralizes the ``path.read_bytes()`` + ``msgspec.json.decode(...)``
pattern that previously appeared in ten cache/validator/api/orchestrator
modules, plus the §11.9.2 "reader must reject a different major" check.
``require_schema_major`` is the canonical schema-major gate; it lives
here (rather than in ``cache/equipment.py`` where it was first written)
so every cache reader -- including ones outside the ``cache`` package
-- can share it without an upward import.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import msgspec
from exlab_wizard.errors import SchemaMajorMismatchError
__all__ = [
"read_msgspec_json",
"read_msgspec_json_raw",
"require_schema_major",
]
[docs]
def require_schema_major(version: str | None, *, expected_major: int) -> None:
"""Raise ``SchemaMajorMismatchError`` when ``version`` is a different major.
Backend Spec §11.9.2 rule 3: a reader at major ``R`` MUST refuse any
file at major ``M != R`` with a structured error. The error carries
``expected_major`` and ``found`` so the caller can report it via the
§4.6.3 error envelope.
A ``None``, empty, or non-``MAJOR.MINOR`` version string is treated
as a major mismatch (the reader cannot tell what version the file
claims to be). Callers that want to be lenient about a missing
version (e.g. a partially-written or corrupt registry file that
should be recovered by rewriting) check that condition themselves
before invoking this helper.
"""
if version is None or not version:
raise SchemaMajorMismatchError(expected_major=expected_major, found=str(version))
try:
found_major = int(version.split(".", 1)[0])
except ValueError as exc:
raise SchemaMajorMismatchError(expected_major=expected_major, found=version) from exc
if found_major != expected_major:
raise SchemaMajorMismatchError(expected_major=expected_major, found=version)
def read_msgspec_json[T](
path: Path,
type_: type[T],
*,
expected_major: int | None = None,
) -> T:
"""Read ``path`` and decode it as ``type_``, optionally gating on major.
When ``expected_major`` is supplied, peeks ``schema_version`` from
the bytes first and raises :class:`SchemaMajorMismatchError` on a
different major before attempting the typed decode. A malformed
JSON file or one without ``schema_version`` is passed through to
the typed decoder, which surfaces the precise validation error.
"""
data = path.read_bytes()
if expected_major is not None:
_peek_and_check_major(data, expected_major=expected_major)
return msgspec.json.decode(data, type=type_)
def read_msgspec_json_raw(
path: Path,
*,
expected_major: int | None = None,
) -> dict[str, Any]:
"""Two-pass read returning the raw dict for migration-default patching.
Some readers need to inspect or mutate the decoded dict (e.g. to
fill in default fields added in a schema-minor bump) before passing
it to ``msgspec.convert``. Use this helper for those cases; for
plain typed reads call :func:`read_msgspec_json` instead.
"""
data = path.read_bytes()
raw: dict[str, Any] = msgspec.json.decode(data, type=dict)
if expected_major is not None:
version = str(raw.get("schema_version", ""))
if version:
require_schema_major(version, expected_major=expected_major)
return raw
def _peek_and_check_major(data: bytes, *, expected_major: int) -> None:
try:
head = msgspec.json.decode(data, type=dict)
except (msgspec.DecodeError, msgspec.ValidationError):
return
version = str(head.get("schema_version", ""))
if not version:
return
require_schema_major(version, expected_major=expected_major)