lockwire 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/bench.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""`lockwire bench`: score detection and forwarding separately, on a corpus.
|
|
2
|
+
|
|
3
|
+
Two measurements, never merged into one number (detection and forwarding):
|
|
4
|
+
|
|
5
|
+
1. **Detection** — for each generated out-of-bound plan, does the *existing*
|
|
6
|
+
evaluator (`plan.validate_plan`, reused — this module never
|
|
7
|
+
re-walks a plan) flag it? Categorized as ``detected`` / ``missed`` (a
|
|
8
|
+
violating case not flagged; "leaked" is a detection
|
|
9
|
+
metric only, and a correctly-forwarded violation is a detection, not a
|
|
10
|
+
leak) / ``false_alarm`` (a compliant case wrongly flagged) /
|
|
11
|
+
``unscorable_correct`` / ``unscorable_mismatch``.
|
|
12
|
+
2. **Forwarding** — every case reaches the simulated driver byte-identical,
|
|
13
|
+
in order, exactly once, *regardless of verdict* (lockwire forwards
|
|
14
|
+
the command unchanged, never originates one, never alters one, never
|
|
15
|
+
withholds one — lockwire has no gate to leak past; scoring forwarding on
|
|
16
|
+
the verdict would build the wrong reading).
|
|
17
|
+
|
|
18
|
+
Self-proving: a corpus that scores well against an evaluator with
|
|
19
|
+
the checks deleted is measuring itself, not the product. `run_bench` takes
|
|
20
|
+
the evaluator as a parameter for exactly this reason — see
|
|
21
|
+
`tests/test_bench.py::test_checks_deleted_detection_rate_collapses`.
|
|
22
|
+
|
|
23
|
+
Ceiling, published in `format_report` itself, not only here: this corpus
|
|
24
|
+
(`tests/fixtures/bench/corpus.yaml`) is hand-authored by the same project
|
|
25
|
+
that wrote the checks it exercises. A bench that only scores cases it wrote
|
|
26
|
+
to be catchable is marketing, not measurement.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Callable, Literal
|
|
32
|
+
|
|
33
|
+
from lockwire.plan import Plan, PlanReport, Step, validate_plan
|
|
34
|
+
from lockwire.report import Verdict, verdict
|
|
35
|
+
from lockwire.schema import Contract, _load_yaml_guarded, _reject_yaml_anchors_aliases, _yaml
|
|
36
|
+
|
|
37
|
+
DEFAULT_CORPUS_PATH = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "bench" / "corpus.yaml"
|
|
38
|
+
|
|
39
|
+
Category = Literal["violating", "compliant", "unscorable"]
|
|
40
|
+
Evaluator = Callable[[Contract, Plan], PlanReport]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class BenchCase:
|
|
45
|
+
id: str
|
|
46
|
+
category: Category
|
|
47
|
+
step: Step
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_corpus(path: Path) -> list[BenchCase]:
|
|
51
|
+
"""Load the corpus YAML (id/category/step per case). Same guarded loader
|
|
52
|
+
as `plan.load_plan` and `schema.load_contract` — a corpus file is trusted
|
|
53
|
+
exactly as much as any other checked-in lockfile-shaped document."""
|
|
54
|
+
with open(path, encoding="utf-8") as f:
|
|
55
|
+
data = _load_yaml_guarded(_yaml(), f)
|
|
56
|
+
_reject_yaml_anchors_aliases(data)
|
|
57
|
+
cases = []
|
|
58
|
+
for raw in data["cases"]:
|
|
59
|
+
# Reuse the plan's own discriminated Step union to validate the case
|
|
60
|
+
# instead of a second step parser.
|
|
61
|
+
step = Plan.model_validate({"steps": [raw["step"]]}).steps[0]
|
|
62
|
+
cases.append(BenchCase(id=raw["id"], category=raw["category"], step=step))
|
|
63
|
+
return cases
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _score(contract: Contract, step: Step, evaluator: Evaluator) -> Verdict:
|
|
67
|
+
return verdict(evaluator(contract, Plan(steps=[step])))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _classify(case: BenchCase, v: Verdict) -> str:
|
|
71
|
+
if case.category == "violating":
|
|
72
|
+
return "detected" if v.code == "NO_GO" else "missed"
|
|
73
|
+
if case.category == "compliant":
|
|
74
|
+
return "false_alarm" if v.code == "NO_GO" else "correct_pass"
|
|
75
|
+
return "unscorable_correct" if v.code == "NOT_SCORED" else "unscorable_mismatch"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def score_and_forward(
|
|
79
|
+
contract: Contract, step: Step, evaluator: Evaluator = validate_plan
|
|
80
|
+
) -> tuple[Verdict, Step]:
|
|
81
|
+
"""Score one step and forward it, unaltered, to the simulated driver.
|
|
82
|
+
|
|
83
|
+
The forward always happens: lockwire has no gate, so nothing it
|
|
84
|
+
scores is ever withheld, including a step scored NO-GO or NOT SCORED.
|
|
85
|
+
Returns ``(verdict, the forwarded step)``; the caller appends the
|
|
86
|
+
forwarded step to its sink so byte-identity is checkable independently
|
|
87
|
+
of the verdict.
|
|
88
|
+
"""
|
|
89
|
+
forwarded = step.model_copy(deep=True)
|
|
90
|
+
return _score(contract, step, evaluator), forwarded
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class BenchResult:
|
|
95
|
+
corpus_size: int
|
|
96
|
+
detected: int
|
|
97
|
+
missed: int
|
|
98
|
+
false_alarms: int
|
|
99
|
+
correct_pass: int
|
|
100
|
+
unscorable_correct: int
|
|
101
|
+
unscorable_mismatch: int
|
|
102
|
+
forwarded: int
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def violating_total(self) -> int:
|
|
106
|
+
return self.detected + self.missed
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def detection_rate(self) -> float | None:
|
|
110
|
+
if self.violating_total == 0:
|
|
111
|
+
return None
|
|
112
|
+
return self.detected / self.violating_total
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def exit_code(self) -> int:
|
|
116
|
+
# The bench tool's own exit reflects whether a hole was caught, not
|
|
117
|
+
# the verdict words of the individual plans it scored (accept line).
|
|
118
|
+
return 1 if self.missed else 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_BUCKETS = (
|
|
122
|
+
"detected",
|
|
123
|
+
"missed",
|
|
124
|
+
"false_alarm",
|
|
125
|
+
"correct_pass",
|
|
126
|
+
"unscorable_correct",
|
|
127
|
+
"unscorable_mismatch",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def run_bench(
|
|
132
|
+
contract: Contract, cases: list[BenchCase], evaluator: Evaluator = validate_plan
|
|
133
|
+
) -> tuple[BenchResult, list[Step]]:
|
|
134
|
+
"""Run every case: score it (detection) and forward it (always). Returns
|
|
135
|
+
the tallied result plus the simulated driver's received-step sink, in
|
|
136
|
+
the order the corpus was walked."""
|
|
137
|
+
counts = dict.fromkeys(_BUCKETS, 0)
|
|
138
|
+
sink: list[Step] = []
|
|
139
|
+
for case in cases:
|
|
140
|
+
v, forwarded = score_and_forward(contract, case.step, evaluator)
|
|
141
|
+
sink.append(forwarded)
|
|
142
|
+
counts[_classify(case, v)] += 1
|
|
143
|
+
result = BenchResult(
|
|
144
|
+
corpus_size=len(cases),
|
|
145
|
+
detected=counts["detected"],
|
|
146
|
+
missed=counts["missed"],
|
|
147
|
+
false_alarms=counts["false_alarm"],
|
|
148
|
+
correct_pass=counts["correct_pass"],
|
|
149
|
+
unscorable_correct=counts["unscorable_correct"],
|
|
150
|
+
unscorable_mismatch=counts["unscorable_mismatch"],
|
|
151
|
+
forwarded=len(sink),
|
|
152
|
+
)
|
|
153
|
+
return result, sink
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def format_report(result: BenchResult, corpus_path: Path) -> str:
|
|
157
|
+
"""The published detection rate, plain words — what the corpus is, how
|
|
158
|
+
many cases, what a catch counts as, and the self-scoring ceiling, all in
|
|
159
|
+
the output itself (not only in a docstring)."""
|
|
160
|
+
rate = result.detection_rate
|
|
161
|
+
rate_str = "n/a (no violating cases in the corpus)" if rate is None else f"{rate:.0%}"
|
|
162
|
+
return "\n".join(
|
|
163
|
+
[
|
|
164
|
+
f"corpus: {corpus_path} — {result.corpus_size} hand-authored cases",
|
|
165
|
+
f"detection rate: {rate_str} "
|
|
166
|
+
f"({result.detected}/{result.violating_total} violating cases flagged NO-GO; "
|
|
167
|
+
'a "catch" = the existing plan evaluator scores the case NO_GO)',
|
|
168
|
+
f"missed (leaked — a violating case the evaluator did not flag): {result.missed}",
|
|
169
|
+
f"false alarms (a compliant case wrongly flagged NO-GO): {result.false_alarms}",
|
|
170
|
+
f"unscorable cases correctly NOT SCORED: {result.unscorable_correct}"
|
|
171
|
+
+ (
|
|
172
|
+
f" ({result.unscorable_mismatch} scored when they should not have been)"
|
|
173
|
+
if result.unscorable_mismatch
|
|
174
|
+
else ""
|
|
175
|
+
),
|
|
176
|
+
f"forwarded to the simulated driver: {result.forwarded}/{result.corpus_size}, "
|
|
177
|
+
"unchanged, in order, once each, regardless of verdict (never withheld)",
|
|
178
|
+
"ceiling: this corpus is hand-authored by the same project that wrote the checks "
|
|
179
|
+
"it exercises; a bench that scores only cases it authored to be catchable is "
|
|
180
|
+
"marketing, not an independent measurement.",
|
|
181
|
+
]
|
|
182
|
+
)
|
lockwire/blame.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""One provenance row per declared record, optionally attributed by git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ruamel.yaml import YAML
|
|
11
|
+
|
|
12
|
+
from lockwire import gitref
|
|
13
|
+
from lockwire.schema import load_contract
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _path_part(item: Any, index: int) -> str:
|
|
17
|
+
if isinstance(item, dict):
|
|
18
|
+
for key in ("id", "name"):
|
|
19
|
+
if key in item:
|
|
20
|
+
return str(item[key])
|
|
21
|
+
return str(index)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _plain(value: Any) -> Any:
|
|
25
|
+
if isinstance(value, dict):
|
|
26
|
+
return {str(key): _plain(child) for key, child in value.items()}
|
|
27
|
+
if isinstance(value, list):
|
|
28
|
+
return [_plain(child) for child in value]
|
|
29
|
+
if isinstance(value, (dt.date, dt.datetime)):
|
|
30
|
+
return value.isoformat()
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _declared(node: Any, path: tuple[str, ...] = ()) -> Iterator[dict[str, Any]]:
|
|
35
|
+
if isinstance(node, dict):
|
|
36
|
+
provenance = node.get("provenance")
|
|
37
|
+
if isinstance(provenance, dict):
|
|
38
|
+
lines = {node.lc.key("provenance")[0] + 1}
|
|
39
|
+
lines.update(provenance.lc.key(key)[0] + 1 for key in provenance)
|
|
40
|
+
row = {
|
|
41
|
+
"path": "/".join(path),
|
|
42
|
+
"value": _plain({key: value for key, value in node.items() if key != "provenance"}),
|
|
43
|
+
"witness": str(provenance["witness"]),
|
|
44
|
+
"source": str(provenance["source"]),
|
|
45
|
+
"locked_at": str(provenance["locked_at"]),
|
|
46
|
+
"lines": lines,
|
|
47
|
+
}
|
|
48
|
+
if provenance.get("compared_from") is not None:
|
|
49
|
+
row["compared_from"] = str(provenance["compared_from"])
|
|
50
|
+
yield row
|
|
51
|
+
for key, child in node.items():
|
|
52
|
+
if key != "provenance":
|
|
53
|
+
yield from _declared(child, (*path, str(key)))
|
|
54
|
+
elif isinstance(node, list):
|
|
55
|
+
for index, child in enumerate(node):
|
|
56
|
+
yield from _declared(child, (*path, _path_part(child, index)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _commits_by_line(porcelain: str) -> dict[int, str]:
|
|
60
|
+
"""Per-line commit shas from a ``git blame --porcelain`` transcript."""
|
|
61
|
+
commits: dict[int, str] = {}
|
|
62
|
+
for line in porcelain.splitlines():
|
|
63
|
+
fields = line.split()
|
|
64
|
+
if (
|
|
65
|
+
len(fields) >= 3
|
|
66
|
+
and len(fields[0]) == 40
|
|
67
|
+
and all(c in "0123456789abcdef" for c in fields[0])
|
|
68
|
+
):
|
|
69
|
+
commits[int(fields[2])] = fields[0]
|
|
70
|
+
return commits
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def rows(contract: Path) -> tuple[list[dict[str, Any]], bool]:
|
|
74
|
+
"""Validated provenance rows and whether the path belongs to a repository.
|
|
75
|
+
|
|
76
|
+
``last_changing_commit`` is omitted when git has no honest attribution (for
|
|
77
|
+
example an untracked file). The stored witness is rendered verbatim: a
|
|
78
|
+
persisted ``" (default)"`` marker is evidence; machine configuration is not.
|
|
79
|
+
"""
|
|
80
|
+
load_contract(contract)
|
|
81
|
+
document = YAML(typ="rt").load(contract)
|
|
82
|
+
result = list(_declared(document))
|
|
83
|
+
|
|
84
|
+
repo = gitref.repo_root(contract)
|
|
85
|
+
relpath = gitref.relpath_in_repo(contract, repo) if repo is not None else None
|
|
86
|
+
porcelain = gitref.blame(relpath, repo=repo) if relpath and repo else None
|
|
87
|
+
commits = _commits_by_line(porcelain or "")
|
|
88
|
+
history = (
|
|
89
|
+
gitref.log(None, [relpath], repo=repo, follow=True)
|
|
90
|
+
if porcelain and relpath and repo
|
|
91
|
+
else None
|
|
92
|
+
)
|
|
93
|
+
order = {sha: index for index, (sha, _, _, _) in enumerate(history or [])}
|
|
94
|
+
for row in result:
|
|
95
|
+
candidates = {commits[line] for line in row.pop("lines") if line in commits}
|
|
96
|
+
candidates.discard("0" * 40)
|
|
97
|
+
attributed = [(order[sha], sha) for sha in candidates if sha in order]
|
|
98
|
+
if attributed:
|
|
99
|
+
row["last_changing_commit"] = min(attributed)[1]
|
|
100
|
+
return result, repo is not None
|
lockwire/catalog.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The parts catalog — a library of parts a user might pick from.
|
|
2
|
+
|
|
3
|
+
Deliberately NOT part of the contract schema (`schema.py`, frozen). A contract is a
|
|
4
|
+
declared, witnessed statement about the robot you have; a catalog is a library of parts you might
|
|
5
|
+
use to build one. They answer different questions and don't share a lifecycle — a catalog entry
|
|
6
|
+
changes when a vendor revises a datasheet, a contract entry changes when this robot's build
|
|
7
|
+
changes. Keeping the catalog its own model in its own file means the freeze on `schema.py` is
|
|
8
|
+
never touched: no field here is a contract field, so there is nothing to validate against
|
|
9
|
+
the contract schema.
|
|
10
|
+
|
|
11
|
+
Every number on a catalog part carries a `kind` and a `source`: a real
|
|
12
|
+
number quotes where it came from; a placeholder says plainly that it is a placeholder. Nothing on
|
|
13
|
+
a catalog part is presented as a real number without one of these two badges — an unlabelled
|
|
14
|
+
number is a bug in the seed data, not a permitted state (see `_source_not_blank` below and
|
|
15
|
+
`tests/test_catalog.py::test_every_seeded_number_has_a_kind_and_a_source`).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
24
|
+
from ruamel.yaml import YAML
|
|
25
|
+
|
|
26
|
+
_SEED_PATH = Path(__file__).parent / "catalog_seed.yaml"
|
|
27
|
+
|
|
28
|
+
# "datasheet": a manufacturer datasheet, spec page or product-listing figure, cited by URL/name.
|
|
29
|
+
# "source_file": a real number read out of a file already in this repo (e.g. a vendor URDF's
|
|
30
|
+
# <mass> tag) — real, but the citation is a file, not a published datasheet.
|
|
31
|
+
# "assumed": fabricated for seeding, no real part backs it; the source string must say so.
|
|
32
|
+
NumberKind = Literal["datasheet", "source_file", "assumed"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SourcedNumber(BaseModel):
|
|
36
|
+
"""One number on a catalog part, badged with where it came from."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
|
|
40
|
+
value: float
|
|
41
|
+
unit: str
|
|
42
|
+
kind: NumberKind
|
|
43
|
+
source: str
|
|
44
|
+
|
|
45
|
+
@field_validator("source")
|
|
46
|
+
@classmethod
|
|
47
|
+
def _source_not_blank(cls, v: str) -> str:
|
|
48
|
+
if not v.strip():
|
|
49
|
+
raise ValueError("source must not be blank — a number without a source is not allowed")
|
|
50
|
+
return v
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CatalogPart(BaseModel):
|
|
54
|
+
"""One entry in the catalog: a part a user might pick, not a claim about a robot they have.
|
|
55
|
+
|
|
56
|
+
Reconciliation with `schema.py`'s `DeviceModel`: two vocabularies for "a part"
|
|
57
|
+
now exist and they are deliberately not one. `CatalogPart` is the SHOPPING vocabulary — a part
|
|
58
|
+
you might pick, sourced numbers, no robot attached. `DeviceModel` is the REGISTRY vocabulary —
|
|
59
|
+
checks, hard limits and known failure modes for a device on a robot you actually have. They
|
|
60
|
+
share only `id` and `family`; nothing else lines up because the two answer different
|
|
61
|
+
questions and don't share a lifecycle (see the module docstring). A consumer that needs both
|
|
62
|
+
must go through one door (the one-door rule), not read both models directly — `grep -rln
|
|
63
|
+
CatalogPart src/lockwire/ tests/` names every module that may reference this class, and
|
|
64
|
+
`test_no_module_straddles_catalog_and_registry_vocabularies` below fails the day that set
|
|
65
|
+
grows to include a module that also references `DeviceModel`. Merging the two, if ever wanted,
|
|
66
|
+
is a `schema.py` change and therefore a maintainer's call — not a refactor to do
|
|
67
|
+
on a reading of "decide the reconciliation."
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
model_config = ConfigDict(extra="forbid")
|
|
71
|
+
|
|
72
|
+
id: str
|
|
73
|
+
family: str
|
|
74
|
+
name: str
|
|
75
|
+
numbers: dict[str, SourcedNumber] = Field(default_factory=dict)
|
|
76
|
+
note: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Catalog(BaseModel):
|
|
80
|
+
model_config = ConfigDict(extra="forbid")
|
|
81
|
+
|
|
82
|
+
parts: list[CatalogPart]
|
|
83
|
+
|
|
84
|
+
@field_validator("parts")
|
|
85
|
+
@classmethod
|
|
86
|
+
def _ids_unique(cls, v: list[CatalogPart]) -> list[CatalogPart]:
|
|
87
|
+
seen: set[str] = set()
|
|
88
|
+
for part in v:
|
|
89
|
+
if part.id in seen:
|
|
90
|
+
raise ValueError(f"duplicate catalog part id: {part.id}")
|
|
91
|
+
seen.add(part.id)
|
|
92
|
+
return v
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_catalog(path: Path | str | None = None) -> Catalog:
|
|
96
|
+
"""Read the catalog from `path` (default: the seeded catalog shipped with lockwire)."""
|
|
97
|
+
yaml = YAML(typ="safe")
|
|
98
|
+
p = Path(path) if path is not None else _SEED_PATH
|
|
99
|
+
data = yaml.load(p.read_text())
|
|
100
|
+
return Catalog.model_validate(data)
|