exlab_wizard.cache.equipment#

Writer for equipment.json (registry) and test_runs.json (marker).

Backend Spec §11.4.1 (equipment.json), §11.4.2 (test_runs.json), §4.4.5 (CacheWriter contract), §11.9 (schema-version policy).

Both files in this module are written rarely:

  • equipment.json – written on the first project creation under an equipment, plus on Settings-driven re-syncs whenever config.yaml is edited. Treated as a registry record (the equipment is a configured workstation peripheral, not an instance of a creation flow).

  • test_runs.json – a marker file written ONCE on the first test run within <equipment>/<project>/TestRuns/. Subsequent test-run creations under the same project do NOT rewrite this file (§11.4.2).

Both writes use the canonical CacheWriter recipe (§4.4.5):

  1. msgspec.json.encode for the typed encode (replaces stdlib json; schema validation lives in the msgspec.Struct types).

  2. Atomic write via tempfile + fsync + os.replace.

  3. Per-file advisory lock via filelock.FileLock so concurrent writers serialize without corrupting the file.

  4. Async wrappers around the synchronous lock+write logic via asyncio.to_thread so the asyncio event loop is not blocked.

The reader path (EquipmentCacheWriter.read_equipment()) does NOT acquire the lock: per §4.4.5 a snapshot read is uncontended, and shared locks in filelock would force the writer to wait on stale readers. A reader that races with a writer will see either the pre-write content (via the original inode) or the post-write content (via the os.replace atomic rename); both are valid points-in-time.

The require_schema_major() helper at module bottom is the shared schema-major gate used by every cache writer in the package – it implements §11.9.2 reader rule 3 (a reader at major R MUST refuse any file at major M != R).

Classes

EquipmentCacheWriter()

Writer for equipment.json and test_runs.json.

class exlab_wizard.cache.equipment.EquipmentCacheWriter[source]#

Bases: object

Writer for equipment.json and test_runs.json.

Instances are stateless; the class serves only to group the two related writers together (per §4.3 package layout). Both write methods are async wrappers; the actual lock + I/O work runs on a worker thread via asyncio.to_thread so the asyncio event loop is not blocked on disk syscalls.

async read_equipment(path)[source]#

Read and decode an existing equipment.json file.

Uses msgspec.json.decode(..., type=EquipmentJson) so schema validation happens in one pass with the decode. No lock is acquired: the writer’s os.replace is atomic, so a racing reader sees either the pre- or post-write inode but never a torn file.

Raises FileNotFoundError (from the underlying Path.read_bytes) if the file does not exist; the caller is responsible for handling the absent-file case.

Parameters:

path (Path)

Return type:

EquipmentJson

async read_test_runs_marker(path)[source]#

Read and decode a test_runs.json marker file.

Raises SchemaMajorMismatchError if the file’s schema major version exceeds the reader’s supported major (1) per §11.9.2.

Parameters:

path (Path)

Return type:

TestRunsJson

async write_equipment(path, payload)[source]#

Write or rewrite the per-equipment equipment.json file.

Stamping rules (§11.4.1):

  • last_modified_at is set to UTC now on every write.

  • first_seen_at is set to UTC now ONLY when the file does not yet exist. On a re-write of an existing file the first_seen_at from the on-disk version is preserved – the spec is explicit that this field is never updated after the first write.

The mutation runs entirely under the per-file advisory lock so a concurrent writer never sees the stale tempfile or a partially-renamed file.

Parameters:
Return type:

None

async write_test_runs_marker(path, payload)[source]#

Write the test_runs.json marker file.

Idempotent per §11.4.2: if the file already exists, this is a no-op (the on-disk content is left untouched even if payload differs). The first write captures the project’s TestRuns subtree as test-only; subsequent test-run creations under the same project leave the marker alone.

The on-disk-existence check is performed under the per-file advisory lock so a concurrent first-time writer does not end up with two competing writes both passing the existence guard and racing on os.replace.

Parameters:
Return type:

None

exlab_wizard.cache.equipment.require_schema_major(version, *, expected_major)[source]#

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.

Parameters:
Return type:

None