"""
The lockfile
============
Which part answered which query, recorded so a later run gives the same board.
:py:class:`Lock` is what a caller touches: it says where the file is and what to
do with it. The modes are :py:class:`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 :py:func:`adopt_orphans`.
The rest of the module is the file itself. :py:class:`Lockfile` reads and writes
it; :py:func:`encode_payload` and :py:func:`decode_payload` are the provider-facing
helpers for getting quantities through JSON and back.
"""
from __future__ import annotations
import json
import os
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field, replace
from enum import Enum, StrEnum
from pathlib import Path
from uuid import uuid4
from typing import TYPE_CHECKING, Any, cast, overload
from logging import getLogger
import jitx.context
from jitx.units import registry
from .error import DimensionMismatch, LockStale, ProviderContractError
from .query import (
Exactly,
Exists,
OneOf,
Predicate,
Range,
Rank,
Scalar,
Tolerance,
is_quantity,
match,
)
from .record import JSON, PartIdentity
if TYPE_CHECKING:
from jitx.project import JitxProject
logger = getLogger(__name__)
SCHEMA_VERSION = 1
# Bumped when the *digest* rules change: the hash payload, the canonical form, the
# set of fields that participate, or the pint version whose spelling reaches it.
# A mismatch means nothing in the file can be matched, which is the one lock
# failure that is genuinely total.
DIGEST_VERSION = 1
[docs]
class LockMode(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"
"""Hermetic: 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."""
OFF = "off"
"""Ignore the lock entirely."""
PINNED_MODES = frozenset({LockMode.READ, LockMode.CHECK, LockMode.REPLAY})
"""Modes where a miss is a failure. `off` and `read-write` grow a design; these
three defend one."""
# --------------------------------------------------------------------------
# The Lock context
# --------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class Lock(jitx.context.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.
"""
path: str | None = None
mode: LockMode = LockMode.READ_WRITE
@property
def reads(self) -> bool:
"""Whether this mode consults the lock at all."""
return self.mode != LockMode.OFF
@property
def writes(self) -> bool:
"""Whether this mode may write. Only `read-write` does."""
return self.mode == LockMode.READ_WRITE
@property
def pinned(self) -> bool:
"""Whether a miss is a failure. True for `read`, `check` and `replay`."""
return self.mode in PINNED_MODES
@property
def resolves(self) -> 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.
"""
return self.mode != LockMode.REPLAY
[docs]
@classmethod
def get(cls) -> Lock:
"""The active lock, or the project's default, never None."""
# The default is a shared instance, never constructed per call: a context
# object's identity is load-bearing.
from .policy import current_project
# `super().get()` raises outside an active instantiation structure, and
# that is correct: a context has no meaning there, so asking for one is
# the error. What this override supplies is the *default within* a run,
# which is what stops `require()` failing merely because no `with` block
# is active.
current = super().get()
lock = current if current is not None else discovered_lock(current_project())
return _forced(lock)
_forced_mode: LockMode | None = None
_forced_locks: dict[Lock, Lock] = {}
[docs]
def force_mode(mode: LockMode | None) -> None:
"""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.
"""
# Process-level rather than a context or thread-local: the jitx runtime
# elaborates a submitted design on its own bridge thread.
global _forced_mode
_forced_mode = mode
_forced_locks.clear()
def _forced(lock: Lock) -> Lock:
if _forced_mode is None or lock.mode == _forced_mode:
return lock
# Cached so one lock maps to one forced lock, for the same identity reason
# `discovered_lock` caches.
if lock not in _forced_locks:
_forced_locks[lock] = replace(lock, mode=_forced_mode)
return _forced_locks[lock]
LOCK_OFF = Lock(mode=LockMode.OFF)
"""The no-project default. A module-level singleton, not an inline constructor:
`get()` must hand back the same object every time."""
_discovered_locks: dict[Path | None, Lock] = {}
[docs]
def discovered_lock(project: JitxProject | None) -> Lock:
"""The project's lock, or `off` when there is no project to put one in.
Takes the project from :py:func:`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
:py:func:`clear_discovered_locks`.
"""
root = project.root if project is not None else None
if root not in _discovered_locks:
_discovered_locks[root] = _lock_for(project)
return _discovered_locks[root]
[docs]
def clear_discovered_locks() -> None:
"""Drop every cached lock. For tests, and for a fresh run in one process."""
_discovered_locks.clear()
def _parse_mode(value: Any, where: Path) -> LockMode:
"""The configured mode, falling back to `read` on anything unrecognized.
`read` rather than the default, deliberately. A project that wrote
`lock-mode` has said it cares how the lock is used, and the safe reading of
"you asked for something I do not understand" is to replay the lock and write
nothing: falling back to `read-write` would let a typo silently re-pin the
design, which is the one outcome a lock exists to prevent. A warning rather
than an error because a misspelled mode must not stop a build that the lock
can answer perfectly well.
"""
try:
return LockMode(value)
except ValueError:
logger.warning(
"%s: unknown lock-mode %r; falling back to %r. Valid modes are %s.",
where,
value,
LockMode.READ.value,
", ".join(repr(m.value) for m in LockMode),
)
return LockMode.READ
def _lock_for(project: JitxProject | None) -> Lock:
from .policy import _project_config
if project is None: # a bare script, a notebook
return LOCK_OFF
config = _project_config(project)
configured = config.get("lock-mode")
# Unset is the default, not a parse failure: most projects never write it.
mode = (
LockMode.READ_WRITE
if configured is None
else _parse_mode(configured, project.root)
)
# Resolved against the project, not the working directory. A configured
# `lock = "parts.lock.json"` otherwise lands wherever the build was started
# from, so the same project would read one lockfile and write another
# depending on where you ran it. Same rule the `csv:` chain paths follow.
return Lock(
path=str(project.root / Path(config.get("lock", "parts.lock.json"))), mode=mode
)
# --------------------------------------------------------------------------
# Entries
# --------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class LockKey:
"""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.
"""
digest: str
chain: str
lock_scope: str = ""
[docs]
@dataclass(frozen=True)
class LockEntry:
"""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.
"""
key: LockKey
query: str
filters: Mapping[str, Predicate]
provider: str
identity: PartIdentity
payload: dict[str, JSON]
adopted_from: str | None = None
# --------------------------------------------------------------------------
# Encoding
# --------------------------------------------------------------------------
[docs]
def encode_value(value: object) -> Any:
"""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.
"""
if is_quantity(value):
if value.dimensionless:
# to_base_units(), not .magnitude: (1 * pct).magnitude is 1, and the
# number it denotes is 0.01. Same trap as `match`'s comparison.
return float(value.to_base_units().magnitude)
return ["q", str(value)]
return value
[docs]
def decode_value(value: object) -> Any:
"""Inverse of :py:func:`encode_value`, through JITX's shared registry."""
if isinstance(value, list) and len(value) == 2 and value[0] == "q":
return registry.Quantity(value[1])
return value
@overload
def encode_payload(value: Mapping[str, object]) -> dict[str, JSON]: ...
@overload
def encode_payload(value: object) -> JSON: ...
[docs]
def encode_payload(value: object) -> JSON:
"""Render a provider payload as JSON, with quantities encoded.
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": ...}`.
"""
if isinstance(value, Mapping):
return {str(k): encode_payload(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
if len(value) == 2 and value[0] == "q":
raise ProviderContractError(
f"payload holds {list(value)!r}, which is how a quantity is "
f"encoded, so reading it back would give a quantity. Store it "
f'under a key instead, such as {{"tag": "q", "value": ...}}'
)
return [encode_payload(v) for v in value]
encoded = encode_value(value)
if not isinstance(encoded, (str, int, float, bool, list)) and encoded is not None:
raise ProviderContractError(
f"payload holds {value!r} of type {type(value).__name__}, which is "
f"not JSON; a payload may hold only JSON values and quantities"
)
return cast(JSON, encoded)
[docs]
def decode_payload(value: JSON) -> Any:
"""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.
"""
if isinstance(value, Mapping):
return {k: decode_payload(v) for k, v in value.items()}
if isinstance(value, list):
if len(value) == 2 and value[0] == "q":
return decode_value(value)
return [decode_payload(v) for v in value]
return value
[docs]
def encode_predicate(predicate: Predicate) -> Any:
from dataclasses import fields as dc_fields
name = type(predicate).__name__
values = [encode_value(getattr(predicate, f.name)) for f in dc_fields(predicate)]
return [name, *values]
[docs]
def decode_predicate(blob: Sequence[Any]) -> Predicate:
"""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.
"""
name, *rest = blob
values = [decode_value(v) for v in rest]
def arity(expected: int) -> None:
if len(values) != expected:
raise ProviderContractError(
f"lock entry has a {name} predicate with {len(values)} "
f"value(s); expected {expected}"
)
match name:
case "Exactly":
arity(1)
return Exactly(values[0])
case "OneOf":
arity(1)
return OneOf(tuple(values[0] or ()))
case "Range":
arity(2)
return Range(values[0], values[1])
case "Tolerance":
arity(3)
return Tolerance(values[0], values[1], values[2])
case "Exists":
arity(1)
return Exists(bool(values[0]))
case _:
raise ProviderContractError(f"lock entry has an unknown predicate {name!r}")
def _encode_attributes(attributes: Mapping[str, Scalar]) -> dict[str, Any]:
return {k: encode_value(v) for k, v in sorted(attributes.items())}
def _decode_attributes(blob: Mapping[str, Any]) -> dict[str, Any]:
return {k: decode_value(v) for k, v in blob.items()}
def _encode_identity(identity: PartIdentity) -> dict[str, Any]:
out: dict[str, Any] = {
"mpn": identity.mpn,
"manufacturer": identity.manufacturer,
"category": identity.category,
"provider": identity.provider,
"attributes": _encode_attributes(identity.attributes),
}
if identity.provider_ref is not None:
out["provider_ref"] = identity.provider_ref
return out
def _decode_identity(blob: Mapping[str, Any]) -> PartIdentity:
return PartIdentity(
mpn=blob["mpn"],
manufacturer=blob.get("manufacturer"),
category=blob.get("category", ""),
provider=blob.get("provider", ""),
provider_ref=blob.get("provider_ref"),
attributes=_decode_attributes(blob.get("attributes", {})),
)
# --------------------------------------------------------------------------
# The file
# --------------------------------------------------------------------------
def _entry_order(entry: LockEntry) -> tuple[str, str, str, str, str]:
"""Where an entry sits in the file, ordered so that diffs stay readable.
Sorts on what a person recognises before the digest, so a query edit whose
part survives leaves its entry in place. Parts with no manufacturer sort
first, together.
"""
return (
entry.key.chain,
entry.key.lock_scope,
entry.identity.manufacturer or "",
entry.identity.mpn,
entry.key.digest,
)
[docs]
@dataclass
class Lockfile:
"""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.
"""
path: Path | None = None
chains: dict[str, dict[str, Any]] = field(default_factory=dict)
entries: dict[LockKey, LockEntry] = field(default_factory=dict)
seen_keys: set[tuple[str, str, str]] = field(
default_factory=set, compare=False, repr=False
)
"""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.
"""
# ---- reading ----------------------------------------------------------
[docs]
@classmethod
def read(cls, path: str | Path | None) -> Lockfile:
"""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.
"""
if path is None:
return cls()
target = Path(path)
if not target.is_file():
return cls(path=target)
with target.open("rb") as handle:
data = json.load(handle)
found = data.get("digest_version", 0)
if found != DIGEST_VERSION:
raise LockStale(found, DIGEST_VERSION)
out = cls(path=target, chains=data.get("chains", {}) or {})
for blob in data.get("entries", []) or []:
key = LockKey(
digest=blob["digest"],
chain=blob["chain"],
lock_scope=blob.get("lock_scope", ""),
)
out.entries[key] = LockEntry(
key=key,
query=blob.get("query", ""),
filters={
k: decode_predicate(v)
for k, v in (blob.get("filters", {}) or {}).items()
},
provider=blob.get("provider", ""),
identity=_decode_identity(blob["identity"]),
# A payload that is not an object came from a hand-edited file.
# Left empty rather than rejected: the provider's own version
# check then raises `StalePayload`, which is recorded and
# re-resolved, which is the designed path for a payload nobody
# can honour.
payload=payload
if isinstance(payload := blob.get("payload"), dict)
else {},
adopted_from=blob.get("adopted_from"),
)
return out
# ---- chain labels -----------------------------------------------------
[docs]
def label_for(self, digest: str, preferred: str | None = None) -> str:
"""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`.
"""
for label, record in self.chains.items():
if record.get("digest") == digest:
return label
if preferred and preferred not in self.chains:
label = preferred
elif "main" not in self.chains:
label = "main"
else:
index = 2
while f"chain-{index}" in self.chains:
index += 1
label = f"chain-{index}"
self.chains[label] = {"digest": digest, "providers": []}
return label
[docs]
def digest_for_label(self, label: str) -> str | None:
record = self.chains.get(label)
return record.get("digest") if record else None
[docs]
def has_chain(self, digest: str) -> bool:
"""Whether any chain record in the file carries this digest."""
return any(r.get("digest") == digest for r in self.chains.values())
[docs]
def entries_for_chain(self, label: str) -> list[LockEntry]:
return [e for e in self.entries.values() if e.key.chain == label]
# ---- writing ----------------------------------------------------------
[docs]
def to_json(self) -> dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"digest_version": DIGEST_VERSION,
"chains": {k: self.chains[k] for k in sorted(self.chains)},
"entries": [
{
"digest": e.key.digest,
"chain": e.key.chain,
"lock_scope": e.key.lock_scope,
"query": e.query,
"filters": {
k: encode_predicate(v) for k, v in sorted(e.filters.items())
},
**(
{"adopted_from": e.adopted_from}
if e.adopted_from is not None
else {}
),
"provider": e.provider,
"identity": _encode_identity(e.identity),
"payload": e.payload,
}
for e in sorted(self.entries.values(), key=_entry_order)
],
}
[docs]
def write(self, path: str | Path | None = None) -> Path:
"""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.
"""
target = Path(path) if path is not None else self.path
if target is None:
raise ValueError("no lockfile path to write to")
target.parent.mkdir(parents=True, exist_ok=True)
blob = json.dumps(self.to_json(), indent=1, sort_keys=False)
# Opened with 0666 so the kernel applies the umask, exactly as it would
# for `open(target, "w")`. `tempfile` creates 0600 and the rename carries
# that over, so the obvious version hands back a lockfile only its author
# can read, and patching that up with a hardcoded 0644 is worse still,
# since it overrides a umask of 077 that was set on purpose.
existing = target.stat().st_mode & 0o777 if target.exists() else None
temp = target.with_name(f"{target.name}.{os.getpid()}.{uuid4().hex}.tmp")
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
try:
if existing is not None:
# A project that chose a mode meant it.
os.fchmod(fd, existing)
with os.fdopen(fd, "w") as handle:
handle.write(blob)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, target)
except BaseException:
temp.unlink(missing_ok=True)
raise
return target
# --------------------------------------------------------------------------
# Adoption
# --------------------------------------------------------------------------
[docs]
class Tier(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."""
def _relaxes(q: Predicate | None, e: Predicate | None) -> bool | None:
"""Whether query predicate `q` relaxes entry predicate `e`.
True for strictly relaxed, False for equal, None for undecided. Deliberately
conservative: an undecided case is not subsumption, so it falls to tier 3
rather than being guessed at.
"""
if q is None and e is None:
return False
if q is None: # the query dropped a constraint
return True
if e is None: # the query added one
return None
if q == e:
return False
if isinstance(q, Tolerance) or isinstance(e, Tolerance):
return None
if isinstance(q, Exists) or isinstance(e, Exists):
return None
if isinstance(q, Range) and isinstance(e, Range):
return True if _range_contains(q, e) else None
if isinstance(q, OneOf) and isinstance(e, OneOf):
return True if set(e.values) < set(q.values) else None
if isinstance(e, Exactly) and isinstance(q, (Range, OneOf)):
from .query import satisfies
try:
return True if satisfies(q, e.value) else None
except DimensionMismatch:
return None
return None
def _range_contains(outer: Range, inner: Range) -> bool:
"""Whether `outer`'s interval contains `inner`'s. Conservative on None bounds."""
from .query import _le
try:
if outer.min is not None:
if inner.min is None or not _le(outer.min, inner.min, ""):
return False
if outer.max is not None:
if inner.max is None or not _le(inner.max, outer.max, ""):
return False
except DimensionMismatch:
return False
return True
[docs]
def subsumes(
query_filters: Mapping[str, Predicate], entry_filters: Mapping[str, Predicate]
) -> bool:
"""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.
"""
keys = set(query_filters) | set(entry_filters)
any_relaxed = False
for key in keys:
verdict = _relaxes(query_filters.get(key), entry_filters.get(key))
if verdict is None:
return False
any_relaxed = any_relaxed or verdict
return any_relaxed
[docs]
def tier_of(entry: LockEntry, query_filters: Mapping[str, Predicate]) -> Tier | None:
"""The strongest relation between an orphan and a query, or None.
The tiers are disjoint by construction: this returns the strongest that holds.
"""
if dict(entry.filters) == dict(query_filters):
return Tier.EXACT
if subsumes(query_filters, entry.filters):
return Tier.SUBSUMED
return Tier.SATISFIED
[docs]
def confirmed(query_filters: Mapping[str, Predicate], entry: LockEntry) -> bool:
"""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.
"""
try:
return match(query_filters, entry.identity.attributes, entry.identity)
except DimensionMismatch:
return False
[docs]
@dataclass
class AdoptionCandidate:
"""A query looking for an orphan to adopt, as the sweep sees it."""
digest: str
chain: str
lock_scope: str
filters: Mapping[str, Predicate]
query: str
payload_key: Any = None
@property
def sort_key(self) -> tuple[str, str, str]:
"""The lock key, which is the one iteration order that cannot tie."""
return (self.digest, self.chain, self.lock_scope)
[docs]
def adopt_orphans(
orphans: Iterable[LockEntry],
unmatched: Iterable[AdoptionCandidate],
) -> dict[str, LockEntry]:
"""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.
"""
pool = list(orphans)
pending = sorted(unmatched, key=lambda c: c.sort_key)
adopted: dict[str, LockEntry] = {}
def sweep(tier: Tier, one_only: bool) -> bool:
progress = False
for candidate in list(pending):
cands = [
o
for o in pool
if o.key.chain == candidate.chain
and o.key.lock_scope == candidate.lock_scope
and tier_of(o, candidate.filters) is tier
and confirmed(candidate.filters, o)
]
if len(cands) == 1:
chosen = cands[0]
adopted["\x00".join(candidate.sort_key)] = chosen
pool.remove(chosen)
pending.remove(candidate)
progress = True
if one_only: # this removal may have unblocked EXACT
break
return progress
while any(
sweep(tier, one_only=tier is not Tier.EXACT)
for tier in (Tier.EXACT, Tier.SUBSUMED, Tier.SATISFIED)
):
pass # restart: a stronger tier may now decide
return adopted
[docs]
def rekey(entry: LockEntry, candidate: AdoptionCandidate) -> LockEntry:
"""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 replace(
entry,
key=LockKey(
digest=candidate.digest,
chain=candidate.chain,
lock_scope=candidate.lock_scope,
),
query=candidate.query,
filters=dict(candidate.filters),
adopted_from=entry.key.digest,
)
__all__ = [
"DIGEST_VERSION",
"SCHEMA_VERSION",
"AdoptionCandidate",
"LOCK_OFF",
"Lock",
"LockEntry",
"LockKey",
"LockMode",
"Lockfile",
"PINNED_MODES",
"Rank",
"Tier",
"adopt_orphans",
"clear_discovered_locks",
"confirmed",
"decode_predicate",
"discovered_lock",
"force_mode",
"encode_predicate",
"rekey",
"subsumes",
"tier_of",
]