lock module#

The lockfile#

Which part answered which query, recorded so a later run gives the same board.

Lock is what a caller touches: it says where the file is and what to do with it. The modes are LockMode: read-write (the default – replay what is there, resolve and record the rest), read (replay, and resolve a miss loudly), check (replay, re-resolve, and report differences), replay (hermetic – replay and adopt, and never ask the chain to resolve) and off. Under the three pinned modes a miss does not raise where it happens: it is recorded and the run continues, and the failure arrives through parts_verdict()’s exit status at the end. A project sets the file and the mode under [tool.jitx.parts]:

[tool.jitx.parts]
lock = "parts.lock.json"
lock-mode = "read-write"

An unset lock-mode is the default; one that does not name a mode logs a warning and falls back to read, so a typo cannot silently re-pin the design.

Lock is a context, so anything wrapping a run – a driver, a test harness, a CLI verb if one is ever built – selects a mode by entering one, and a Design that declares its own overrides the project default. Nothing has to be called by hand: an ordinary run pins at the end of elaboration.

A lock exists to stop external change from moving a design, not internal change. That principle is what the keying follows from: an entry is keyed on the query digest, the active chain, and a user-supplied lock_scope, so editing the chain re-resolves (internal, deliberate) while a catalogue gaining rows does not (external, the world moving on).

A digest miss is recoverable, which is why editing one query does not churn the parts nobody touched. Whether a locked part still satisfies an edited query is checkable locally (the entry stores its attributes and the core has match) so at the final elaboration flush orphaned entries are matched against unmatched queries by the tiered sweep in adopt_orphans().

The rest of the module is the file itself. Lockfile reads and writes it; encode_payload() and decode_payload() are the provider-facing helpers for getting quantities through JSON and back.

class LockMode(*values)[source]#

Bases: StrEnum

What a run does with the lockfile.

A StrEnum, so the member is the config spelling: LockMode(“read-write”) parses what [tool.jitx.parts] lock-mode holds, and a design that spells a mode as a plain string still compares equal.

READ_WRITE = 'read-write'#

Replay what is there, resolve and record the rest. The default.

READ = 'read'#

Replay only. A miss still resolves, so the board is complete and the report is, but it is recorded and fails the run at the end.

CHECK = 'check'#

Run normally, then resolve the whole design cleanly and report where the board this run built differs from what the clean run would build. Every bound part is compared, adoptions included. The run’s own part is always the one that gets built; this mode only reports.

REPLAY = 'replay'#

the chain is never asked to resolve. A miss is recorded as unresolvable and its component is left unbound. Replay and adoption still work – both reach their answer from the lockfile and build it with rehydrate, which is local.

Type:

Hermetic

OFF = 'off'#

Ignore the lock entirely.

PINNED_MODES = frozenset({LockMode.CHECK, LockMode.READ, LockMode.REPLAY})#

Modes where a miss is a failure. off and read-write grow a design; these three defend one.

class Lock(path=None, mode=LockMode.READ_WRITE)[source]#

Bases: Context

Which lockfile to use, and how.

A context, so anything wrapping a run selects a mode by entering one, and a Design that declares its own lock overrides the project default like any other context.

read-write is the default: running a design twice with no changes should give the same board, and the only way to get that is for the first run to pin.

Parameters:
path: str | None = None#
mode: LockMode = 'read-write'#
property reads: bool#

Whether this mode consults the lock at all.

property writes: bool#

Whether this mode may write. Only read-write does.

property pinned: bool#

Whether a miss is a failure. True for read, check and replay.

property resolves: bool#

Whether a miss may be sent to the chain. False only for replay.

off resolves everything and consults no lock; replay is its mirror image, consulting only the lock and asking the chain nothing.

classmethod get()[source]#

The active lock, or the project’s default, never None.

Return type:

Lock

force_mode(mode)[source]#

Run every lock in the process under mode, or stop doing so with None.

For a driver that must select a mode around a design it cannot wrap in a Lock context, such as an exporter. Each lock keeps its path and only its mode is replaced, and the override applies to a design that declares its own Lock as well.

Return type:

None

Parameters:

mode (LockMode | None)

LOCK_OFF = Lock(path=None, mode=<LockMode.OFF: 'off'>)#

The no-project default. A module-level singleton, not an inline constructor: get() must hand back the same object every time.

discovered_lock(project)[source]#

The project’s lock, or off when there is no project to put one in.

Takes the project from jitxlib.parts.policy.current_project(), which has already read its config. Keyed and cached exactly as discovered_source is, and for the same two reasons: identity stability, and not re-reading config for every part in the design. Reset with clear_discovered_locks().

Return type:

Lock

Parameters:

project (JitxProject | None)

clear_discovered_locks()[source]#

Drop every cached lock. For tests, and for a fresh run in one process.

Return type:

None

class LockKey(digest, chain, lock_scope='')[source]#

Bases: object

What an entry is keyed on.

The digest gives query identity, the chain says what was asked, and lock_scope disambiguates queries that should be pinned separately.

Parameters:
digest: str#
chain: str#
lock_scope: str = ''#
class LockEntry(key, query, filters, provider, identity, payload, adopted_from=None)[source]#

Bases: object

One pinned selection.

provider is the chain member, which is what is installed and what implements rehydrate; identity.provider records where the part actually came from, which for a delegating provider is not the same thing.

Parameters:
key: LockKey#
query: str#
filters: Mapping[str, Predicate]#
provider: str#
identity: PartIdentity#
payload: dict[str, JSON]#
adopted_from: str | None = None#
encode_value(value)[source]#

Encode an attribute or bound for JSON.

Quantities use pint’s own string form, tagged so nothing has to be sniffed back. A dimensionless bound stays a plain number, which is why [“Range”, null, 0.01] is right for AtMost(1 * pct) rather than an inconsistency with the encoder.

Return type:

Any

Parameters:

value (object)

decode_value(value)[source]#

Inverse of encode_value(), through JITX’s shared registry.

Return type:

Any

Parameters:

value (object)

encode_payload(value)[source]#

Render a provider payload as JSON, with quantities encoded.

Overloads:
Parameters:

value (object)

Return type:

JSON

For ResolvedPart.payload, which the core stores verbatim and never serializes. Walks dicts and lists, and takes a bare value too:

payload = encode_payload({“rated”: 35 * V, “series”: [“E24”, “E96”]})

decode_payload is the inverse, so rehydrate gets its quantities back.

Two asymmetries to know about. A dimensionless quantity encodes as its plain magnitude, so 1 * pct returns as 0.01 rather than as a quantity, matching how attributes and filters are stored. And a tuple encodes as a list, so it returns as a list.

Refuses a two-item list or tuple starting with “q”, at any depth, since that is how a quantity is encoded and decoding would hand back one. Store such data under a key instead, as {“tag”: “q”, “value”: …}.

decode_payload(value)[source]#

Inverse of encode_payload, restoring quantities.

A two-item list starting with “q” is read back as a quantity. encode_payload refuses that shape as raw data, so anything it produced round-trips.

Return type:

Any

Parameters:

value (JSON)

encode_predicate(predicate)[source]#
Return type:

Any

Parameters:

predicate (Predicate)

decode_predicate(blob)[source]#

Rebuild a predicate from its encoded form.

Written out per type rather than as cls(*values). Two reasons: a splat over a decoded list is untypeable without a cast, and an entry with the wrong arity for its predicate name should be a clear error rather than a TypeError out of a constructor.

Return type:

Predicate

Parameters:

blob (Sequence[Any])

class Lockfile(path=None, chains=dict(), entries=dict(), seen_keys=set())[source]#

Bases: object

A parsed lockfile, plus the chain interning table.

Chains are interned at the top of the file rather than repeated per entry, because a design may resolve under more than one: any with PartSource(…) block introduces another.

Parameters:
path: Path | None = None#
chains: dict[str, dict[str, Any]]#
entries: dict[LockKey, LockEntry]#
seen_keys: set[tuple[str, str, str]]#

Keys claimed by a query this run, as (digest, chain, lock_scope).

Run state rather than file content: never read from or written to the JSON, and excluded from comparison and repr so it cannot be mistaken for either.

It lives here because it is keyed the way entries are, and because it is shared exactly when the file is. Two designs in one process writing one lockfile must share their claims, or the second adopts entries the first is holding; two designs writing different lockfiles must not.

classmethod read(path)[source]#

Parse a lockfile, or return an empty one if it does not exist.

Raises LockStale on a digest_version mismatch, which is the only lock failure detectable before elaboration and the only one that is total.

Return type:

Lockfile

Parameters:

path (str | Path | None)

label_for(digest, preferred=None)[source]#

The stable short handle for a chain digest, assigning one if needed.

Deterministic so that regenerating a lock does not reshuffle every entry: main for the design-level chain, then whatever label a with block was given, else chain-2, chain-3.

Return type:

str

Parameters:
  • digest (str)

  • preferred (str | None)

digest_for_label(label)[source]#
Return type:

str | None

Parameters:

label (str)

has_chain(digest)[source]#

Whether any chain record in the file carries this digest.

Return type:

bool

Parameters:

digest (str)

entries_for_chain(label)[source]#
Return type:

list[LockEntry]

Parameters:

label (str)

to_json()[source]#
Return type:

dict[str, Any]

write(path=None)[source]#

Write atomically: temp file in the same directory, fsync, rename.

A rename is atomic on every platform that matters, so a lockfile is never observed half-written and an interrupted commit leaves the previous one intact.

Return type:

Path

Parameters:

path (str | Path | None)

class Tier(*values)[source]#

Bases: Enum

Adoption tiers, strongest signal first.

What adoption protects is the layout, not the bill of materials: a part is a footprint that has been placed, routed around and fitted next to its neighbours, so re-resolution handing back a different size invalidates work built on top of it.

EXACT = 1#

The orphan’s recorded filters equal the query’s current filters. The rank-edit case, where the BOM would churn.

SUBSUMED = 2#

The query’s filters are a decidable relaxation of the orphan’s. The widening case, where the layout would churn, which is worse.

SATISFIED = 3#

No filter relationship, but the part satisfies the query. Weakest signal, broadest safety net.

subsumes(query_filters, entry_filters)[source]#

Whether the query’s filter set is a decidable relaxation of the entry’s.

Every key relaxed-or-equal, and at least one strictly relaxed.

Return type:

bool

Parameters:
tier_of(entry, query_filters)[source]#

The strongest relation between an orphan and a query, or None.

The tiers are disjoint by construction: this returns the strongest that holds.

Return type:

Tier | None

Parameters:
confirmed(query_filters, entry)[source]#

Whether the entry’s recorded part satisfies the query’s current filters.

Part of candidacy, not a check on a chosen candidate: an entry whose recorded part does not satisfy the query was never a candidate for it, and letting it manufacture an ambiguity would let one hand-edited entry unpin a legitimate match.

A raise means “not a candidate” rather than aborting the sweep. Per candidate, not per pass: one unusable entry must not cost every other query its pin.

Return type:

bool

Parameters:
class AdoptionCandidate(digest, chain, lock_scope, filters, query, payload_key=None)[source]#

Bases: object

A query looking for an orphan to adopt, as the sweep sees it.

Parameters:
digest: str#
chain: str#
lock_scope: str#
filters: Mapping[str, Predicate]#
query: str#
payload_key: Any = None#
property sort_key: tuple[str, str, str]#

The lock key, which is the one iteration order that cannot tie.

adopt_orphans(orphans, unmatched)[source]#

Match orphaned entries against unmatched queries.

Returns a mapping from a candidate’s lock key triple (joined) to the entry it adopted. Tier-major, and it returns to tier 1 after every weak adoption: a removal can make a stronger tier decidable for another query, and a loop that only restarts between passes enforces the tiering invariant between passes rather than at every moment.

Return type:

dict[str, LockEntry]

Parameters:
rekey(entry, candidate)[source]#

Re-key an adopted entry to the query that adopted it.

Rewriting the filters is not optional: leave them and the file holds an entry whose digest describes one query and whose filters describe another, so the next edit matches against stale filters, wrong for exactly the entries that have been adopted once.

Return type:

LockEntry

Parameters: