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/edit.py
ADDED
|
@@ -0,0 +1,2098 @@
|
|
|
1
|
+
"""``lockwire declare`` / ``lockwire apply`` / ``lockwire add`` — the edit primitive.
|
|
2
|
+
|
|
3
|
+
The YAML is the truth. Every edit lands as one declared fact carrying a
|
|
4
|
+
source and a witness, written through a ruamel round-trip load/modify/dump
|
|
5
|
+
so existing comments, key order and quoting style survive untouched outside the
|
|
6
|
+
edited lines. Nothing is written unless the edited document validates through
|
|
7
|
+
``Contract`` first — the same byte cap and anchor/alias refusal the rest of the
|
|
8
|
+
codebase applies to a lockfile apply here too.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import difflib
|
|
15
|
+
import fcntl
|
|
16
|
+
import hashlib
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import tempfile
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from contextvars import ContextVar
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from functools import wraps
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from ruamel.yaml import YAML, CommentedMap
|
|
32
|
+
from ruamel.yaml.constructor import RoundTripConstructor
|
|
33
|
+
from ruamel.yaml.representer import RoundTripRepresenter
|
|
34
|
+
from ruamel.yaml.scalarfloat import ScalarFloat
|
|
35
|
+
|
|
36
|
+
from lockwire.schema import (
|
|
37
|
+
Contract,
|
|
38
|
+
MAX_CONTRACT_BYTES,
|
|
39
|
+
_guard_contract_raw,
|
|
40
|
+
_load_yaml_guarded,
|
|
41
|
+
_model_structure_hash,
|
|
42
|
+
_reject_yaml_anchors_aliases,
|
|
43
|
+
SCHEMA_VERSION,
|
|
44
|
+
SUPPORTED_SCHEMA_VERSIONS,
|
|
45
|
+
UnsupportedSchemaVersion,
|
|
46
|
+
load_contract,
|
|
47
|
+
)
|
|
48
|
+
from lockwire import units
|
|
49
|
+
from lockwire.report import LIMIT_RAISED_MARK
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EditRefusal(ValueError):
|
|
53
|
+
"""A declare/apply/add refused before writing anything."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EditConflict(EditRefusal):
|
|
57
|
+
"""The bytes a caller edited are no longer the bytes on disk."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EditRecoveryError(OSError):
|
|
61
|
+
"""A failed edit left recovery bytes in a named artifact."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
recovery_artifact: Path,
|
|
66
|
+
replace_error: BaseException,
|
|
67
|
+
recovery_error: BaseException,
|
|
68
|
+
*,
|
|
69
|
+
contract_state: str | None = None,
|
|
70
|
+
backup_state: str | None = None,
|
|
71
|
+
recovery_action: str = "rollback",
|
|
72
|
+
):
|
|
73
|
+
self.recovery_artifact = recovery_artifact
|
|
74
|
+
self.contract_state = contract_state
|
|
75
|
+
self.backup_state = backup_state
|
|
76
|
+
self.recovery_action = recovery_action
|
|
77
|
+
if contract_state is None:
|
|
78
|
+
message = (
|
|
79
|
+
f"contract replacement failed ({replace_error}); backup rollback failed "
|
|
80
|
+
f"({recovery_error}); prior undo bytes are at {recovery_artifact}"
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
message = (
|
|
84
|
+
f"contract replacement failed ({replace_error}); backup {recovery_action} failed "
|
|
85
|
+
f"({recovery_error}); contract is {contract_state}; {backup_state} at "
|
|
86
|
+
f"{recovery_artifact}"
|
|
87
|
+
)
|
|
88
|
+
super().__init__(message)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_locked_contracts: ContextVar[frozenset[Path]] = ContextVar("locked_contracts", default=frozenset())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def contract_revision(path: Path | str) -> str:
|
|
95
|
+
"""Opaque revision for the exact contract bytes, not its semantic hash."""
|
|
96
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@contextmanager
|
|
100
|
+
def _contract_lock(path: Path) -> Iterator[None]:
|
|
101
|
+
"""Serialize cooperating writers with a sibling advisory lock."""
|
|
102
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
103
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
104
|
+
try:
|
|
105
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
106
|
+
yield
|
|
107
|
+
finally:
|
|
108
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
109
|
+
os.close(fd)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _locked_write(function: Any) -> Any:
|
|
113
|
+
"""Lock, then compare raw bytes before any writer reads its document."""
|
|
114
|
+
|
|
115
|
+
@wraps(function)
|
|
116
|
+
def wrapped(
|
|
117
|
+
contract_path: Path, *args: Any, expected_revision: str | None = None, **kwargs: Any
|
|
118
|
+
) -> Any:
|
|
119
|
+
resolved = contract_path.resolve()
|
|
120
|
+
if resolved in _locked_contracts.get():
|
|
121
|
+
return function(contract_path, *args, expected_revision=expected_revision, **kwargs)
|
|
122
|
+
with _contract_lock(resolved):
|
|
123
|
+
actual = contract_revision(resolved)
|
|
124
|
+
if expected_revision is not None and expected_revision != actual:
|
|
125
|
+
raise EditConflict(
|
|
126
|
+
"contract changed since it was opened; reload before applying this edit"
|
|
127
|
+
)
|
|
128
|
+
token = _locked_contracts.set(_locked_contracts.get() | {resolved})
|
|
129
|
+
try:
|
|
130
|
+
return function(resolved, *args, expected_revision=expected_revision, **kwargs)
|
|
131
|
+
finally:
|
|
132
|
+
_locked_contracts.reset(token)
|
|
133
|
+
|
|
134
|
+
return wrapped
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# the style `init` writes when no existing list tells us otherwise —
|
|
138
|
+
# schema.py's `save_contract` uses the same setting, so the two agree.
|
|
139
|
+
_DEFAULT_SEQUENCE_STYLE = (4, 2) # (sequence indent, dash offset)
|
|
140
|
+
|
|
141
|
+
_SEQUENCE_STYLE_RE = re.compile(r"^([ \t]*)\S[^\n:]*:[ \t]*\n\1([ \t]*)-(?:[ \t]|$)", re.MULTILINE)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _sniff_sequence_style(text: str) -> tuple[int, int]:
|
|
145
|
+
"""This file's own list style: (sequence indent, dash offset).
|
|
146
|
+
|
|
147
|
+
An `init`-written contract opens a list right under its key
|
|
148
|
+
(``links:\\n - name:``, offset 0); a file the editor already touched, or
|
|
149
|
+
one written by hand at the tool's other historic default, indents the
|
|
150
|
+
dash under its key (``devices:\\n - id:``, offset 2). ruamel's indent
|
|
151
|
+
setting is document-wide, not per-key, so ANY block list in the file
|
|
152
|
+
(top-level or nested) tells us the whole file's style — read it from the
|
|
153
|
+
first ``key:`` line immediately followed by a ``- `` item at the same or
|
|
154
|
+
deeper indent. No block list anywhere (a brand-new/list-less file) falls
|
|
155
|
+
back to the same style `init` writes, so the two always agree.
|
|
156
|
+
"""
|
|
157
|
+
match = _SEQUENCE_STYLE_RE.search(text)
|
|
158
|
+
if not match:
|
|
159
|
+
return _DEFAULT_SEQUENCE_STYLE
|
|
160
|
+
offset = len(match.group(2))
|
|
161
|
+
return offset + 2, offset
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_SRC_TEXT_ATTR = "_lockwire_src_text"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _construct_yaml_float_keeping_src(constructor: Any, node: Any) -> Any:
|
|
168
|
+
"""Load a float the normal ruamel way, then remember its exact source text.
|
|
169
|
+
|
|
170
|
+
`ScalarFloat` does not keep the characters it was parsed from — only
|
|
171
|
+
width/precision/exponent metadata, which its own representer uses to
|
|
172
|
+
*reconstruct* a string. That reconstruction is lossy: on a full
|
|
173
|
+
double-precision mantissa it can drift the last digit by one ULP even
|
|
174
|
+
though the field was never touched (seen on a
|
|
175
|
+
real `init anymal_d.urdf` -> `add family`: 10 untouched floats drifted —
|
|
176
|
+
comparing the reconstructed text's parsed *value* to the original
|
|
177
|
+
misses all of them, since a drifted string still parses back to
|
|
178
|
+
the same float64). Stashing the raw text here and having the representer
|
|
179
|
+
(below) emit it verbatim for an unchanged node is the only reliable fix.
|
|
180
|
+
"""
|
|
181
|
+
value = RoundTripConstructor.construct_yaml_float(constructor, node)
|
|
182
|
+
if isinstance(value, ScalarFloat):
|
|
183
|
+
setattr(value, _SRC_TEXT_ATTR, node.value)
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _represent_scalar_float_keeping_src(representer: Any, data: Any) -> Any:
|
|
188
|
+
"""Emit a `ScalarFloat`'s ORIGINAL text verbatim when it carries one.
|
|
189
|
+
|
|
190
|
+
Only a node untouched since load keeps `_lockwire_src_text` (it lives on
|
|
191
|
+
the same `ScalarFloat` instance `_construct_yaml_float_keeping_src`
|
|
192
|
+
tagged); anything `declare`/`add` set is a plain float/int/new
|
|
193
|
+
`ScalarFloat` from `parse_value` and goes through ruamel's normal
|
|
194
|
+
(reconstructing) path instead — which is fine, it has no "original" text
|
|
195
|
+
to preserve.
|
|
196
|
+
"""
|
|
197
|
+
node = RoundTripRepresenter.represent_scalar_float(representer, data)
|
|
198
|
+
src = getattr(data, _SRC_TEXT_ATTR, None)
|
|
199
|
+
if src is not None:
|
|
200
|
+
node.value = src
|
|
201
|
+
return node
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _yaml(sequence: int = 4, offset: int = 2) -> YAML:
|
|
205
|
+
"""This module's own round-trip settings — never schema.py's loader.
|
|
206
|
+
|
|
207
|
+
Preserve existing quote characters, the file's own sniffed list style
|
|
208
|
+
(see `_sniff_sequence_style`), and never re-wrap a long line (ruamel's
|
|
209
|
+
default width is 80 and counts against every scalar, not just prose — a
|
|
210
|
+
long provenance note would otherwise get rewrapped on every write that
|
|
211
|
+
merely touches a sibling key). The float constructor/representer pair is
|
|
212
|
+
registered so an untouched float scalar survives byte-for-byte.
|
|
213
|
+
"""
|
|
214
|
+
y = YAML()
|
|
215
|
+
y.default_flow_style = False
|
|
216
|
+
y.preserve_quotes = True
|
|
217
|
+
y.indent(mapping=2, sequence=sequence, offset=offset)
|
|
218
|
+
y.width = 10_000
|
|
219
|
+
y.constructor.add_constructor("tag:yaml.org,2002:float", _construct_yaml_float_keeping_src)
|
|
220
|
+
y.representer.add_representer(ScalarFloat, _represent_scalar_float_keeping_src)
|
|
221
|
+
return y
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------- value parsing
|
|
225
|
+
|
|
226
|
+
_BOOL = {"true": True, "false": False}
|
|
227
|
+
_UNIT_CHARS = r"[A-Za-z0-9µ/*^%]+"
|
|
228
|
+
_NUM = r"-?\d+(?:\.\d+)?"
|
|
229
|
+
_SINGLE_RE = re.compile(rf"^({_NUM})\s*({_UNIT_CHARS})$")
|
|
230
|
+
_TRIPLE_RE = re.compile(rf"^\[?\s*({_NUM})[,\s]+({_NUM})[,\s]+({_NUM})\s*\]?\s+({_UNIT_CHARS})$")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _flow_quantity(value: float, unit: str) -> CommentedMap:
|
|
234
|
+
"""A `{value, unit}` quantity written flow-style, matching every one in the fixtures."""
|
|
235
|
+
q = CommentedMap([("value", value), ("unit", unit)])
|
|
236
|
+
q.fa.set_flow_style()
|
|
237
|
+
return q
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_value(raw: str, *, field_name: str) -> Any:
|
|
241
|
+
"""Parse a `declare`/`apply` VALUE string into a YAML-writable python value.
|
|
242
|
+
|
|
243
|
+
Grammar: a bare number -> number; "25 W" -> {value, unit}; a triple
|
|
244
|
+
("0.12 0 0.05 m" or "[0.12, 0, 0.05] m") -> a 3-tuple of quantities, only
|
|
245
|
+
for ``mount_xyz``; true/false -> bool; a quoted string -> string; anything
|
|
246
|
+
else is kept as a plain string.
|
|
247
|
+
"""
|
|
248
|
+
text = raw.strip()
|
|
249
|
+
if field_name == "mount_shape":
|
|
250
|
+
try:
|
|
251
|
+
value = json.loads(text)
|
|
252
|
+
except json.JSONDecodeError as error:
|
|
253
|
+
raise EditRefusal(
|
|
254
|
+
"mount_shape must be a JSON object with kind and dimensions_mm"
|
|
255
|
+
) from error
|
|
256
|
+
if not isinstance(value, dict):
|
|
257
|
+
raise EditRefusal("mount_shape must be a JSON object")
|
|
258
|
+
return value
|
|
259
|
+
if text.lower() in _BOOL:
|
|
260
|
+
return _BOOL[text.lower()]
|
|
261
|
+
if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
|
|
262
|
+
return text[1:-1]
|
|
263
|
+
try:
|
|
264
|
+
return int(text)
|
|
265
|
+
except ValueError:
|
|
266
|
+
pass
|
|
267
|
+
try:
|
|
268
|
+
return float(text)
|
|
269
|
+
except ValueError:
|
|
270
|
+
pass
|
|
271
|
+
if field_name == "mount_xyz":
|
|
272
|
+
triple = _TRIPLE_RE.match(text)
|
|
273
|
+
if triple:
|
|
274
|
+
x, y, z, unit = triple.groups()
|
|
275
|
+
if not units.known(unit):
|
|
276
|
+
raise EditRefusal(f"unknown unit {unit!r} in {raw!r}")
|
|
277
|
+
return [
|
|
278
|
+
_flow_quantity(float(x), unit),
|
|
279
|
+
_flow_quantity(float(y), unit),
|
|
280
|
+
_flow_quantity(float(z), unit),
|
|
281
|
+
]
|
|
282
|
+
single = _SINGLE_RE.match(text)
|
|
283
|
+
if single:
|
|
284
|
+
value, unit = single.groups()
|
|
285
|
+
if units.known(unit):
|
|
286
|
+
return _flow_quantity(float(value), unit)
|
|
287
|
+
raise EditRefusal(f"unknown unit {unit!r} in {raw!r}")
|
|
288
|
+
return text
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _attach_unit(value: Any, unit: str) -> Any:
|
|
292
|
+
"""Merge a change entry's sibling `unit:` key into its `value:`.
|
|
293
|
+
|
|
294
|
+
`lockwire scene import` writes the raw model numbers this way: a bare
|
|
295
|
+
3-list for a mount_xyz triple, or a bare number for a single quantity —
|
|
296
|
+
the schema's `{value, unit}` shape is the schema layer's job, not the importer's.
|
|
297
|
+
"""
|
|
298
|
+
if not units.known(unit):
|
|
299
|
+
raise EditRefusal(f"unknown unit {unit!r}")
|
|
300
|
+
if (
|
|
301
|
+
isinstance(value, list)
|
|
302
|
+
and len(value) == 3
|
|
303
|
+
and all(isinstance(v, int | float) for v in value)
|
|
304
|
+
):
|
|
305
|
+
return [_flow_quantity(float(v), unit) for v in value]
|
|
306
|
+
if isinstance(value, int | float):
|
|
307
|
+
return _flow_quantity(float(value), unit)
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def format_value(value: Any) -> str:
|
|
312
|
+
"""Render a parsed value the way `declared ...` and diffs describe it."""
|
|
313
|
+
if isinstance(value, bool):
|
|
314
|
+
return "true" if value else "false"
|
|
315
|
+
if isinstance(value, dict) and "value" in value and "unit" in value:
|
|
316
|
+
return f"{value['value']:g} {value['unit']}"
|
|
317
|
+
if (
|
|
318
|
+
isinstance(value, list)
|
|
319
|
+
and value
|
|
320
|
+
and all(isinstance(v, dict) and "value" in v and "unit" in v for v in value)
|
|
321
|
+
):
|
|
322
|
+
nums = ", ".join(f"{v['value']:g}" for v in value)
|
|
323
|
+
return f"[{nums}] {value[0]['unit']}"
|
|
324
|
+
return str(value)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------- path grammar
|
|
328
|
+
|
|
329
|
+
_TOPOLOGY_OWNER_COLLECTIONS = {"rails", "buses", "hosts", "wires"}
|
|
330
|
+
_TOP_LEVEL_ID_COLLECTIONS = {"devices", "device_models", "budgets", "families"}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def split_path(path: str) -> list[str]:
|
|
334
|
+
segments = [s for s in path.split("/") if s]
|
|
335
|
+
if not segments:
|
|
336
|
+
raise EditRefusal(f"empty path {path!r}")
|
|
337
|
+
return segments
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _navigate(doc: Any, segments: list[str], *, create: bool) -> Any:
|
|
341
|
+
"""Walk `doc` along `segments`; create missing dict children when `create`.
|
|
342
|
+
|
|
343
|
+
Never creates a new list entry — reaching a list segment that no id/name
|
|
344
|
+
matches is always a refusal (that is `lockwire add`'s job, not declare's).
|
|
345
|
+
"""
|
|
346
|
+
node = doc
|
|
347
|
+
walked: list[str] = []
|
|
348
|
+
for seg in segments:
|
|
349
|
+
walked.append(seg)
|
|
350
|
+
if isinstance(node, dict):
|
|
351
|
+
if seg not in node or node[seg] is None:
|
|
352
|
+
if not create:
|
|
353
|
+
raise EditRefusal(f"no key {seg!r} at {'/'.join(walked)}")
|
|
354
|
+
node[seg] = CommentedMap()
|
|
355
|
+
node = node[seg]
|
|
356
|
+
elif isinstance(node, list):
|
|
357
|
+
hit = next(
|
|
358
|
+
(
|
|
359
|
+
x
|
|
360
|
+
for x in node
|
|
361
|
+
if isinstance(x, dict) and (x.get("id") == seg or x.get("name") == seg)
|
|
362
|
+
),
|
|
363
|
+
None,
|
|
364
|
+
)
|
|
365
|
+
if hit is None:
|
|
366
|
+
raise EditRefusal(
|
|
367
|
+
f"no entry with id/name {seg!r} at {'/'.join(walked)} "
|
|
368
|
+
"(use `lockwire add` to create a new device/rail/bus)"
|
|
369
|
+
)
|
|
370
|
+
node = hit
|
|
371
|
+
else:
|
|
372
|
+
raise EditRefusal(f"{'/'.join(walked)} reached a leaf, not a container")
|
|
373
|
+
return node
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def provenance_owner_path(segments: list[str]) -> list[str]:
|
|
377
|
+
"""Prefix of `segments` naming the block whose `provenance` owns this fact."""
|
|
378
|
+
if (
|
|
379
|
+
len(segments) >= 3
|
|
380
|
+
and segments[0] == "topology"
|
|
381
|
+
and segments[1] in _TOPOLOGY_OWNER_COLLECTIONS
|
|
382
|
+
):
|
|
383
|
+
return segments[:3]
|
|
384
|
+
if len(segments) >= 2 and segments[0] in _TOP_LEVEL_ID_COLLECTIONS:
|
|
385
|
+
return segments[:2]
|
|
386
|
+
if len(segments) >= 2 and segments[0] == "topology" and segments[1] == "battery":
|
|
387
|
+
return segments[:2]
|
|
388
|
+
# `model/links` is a name-addressed list, same shape as the
|
|
389
|
+
# topology owner collections above -- the owning block is the individual
|
|
390
|
+
# link entry (`model/links/<name>`), not the `links` list itself. Checked
|
|
391
|
+
# BEFORE the general `model/...` fallback below so a link's own name-scoped
|
|
392
|
+
# owner wins over the whole-record catch-all that would otherwise claim it.
|
|
393
|
+
if len(segments) >= 3 and segments[0] == "model" and segments[1] == "links":
|
|
394
|
+
return segments[:3]
|
|
395
|
+
# The model record owns every OTHER fact under it. `model/joints` (and
|
|
396
|
+
# `model/links` before the branch above existed) are LISTS, and neither
|
|
397
|
+
# `Joint` nor `Link` carries a `provenance` field (schema.py, frozen),
|
|
398
|
+
# so the default `segments[:2]` resolved the owner to a list and
|
|
399
|
+
# refused every `model/...` declare outright:
|
|
400
|
+
# `declare model/joints/imu_mount/limits/effort 3.0` exited 2 with "cannot
|
|
401
|
+
# stamp provenance at model/joints". `ModelRecord.provenance` is the block
|
|
402
|
+
# that already covers the structure, so it is the owner.
|
|
403
|
+
if segments[0] == "model":
|
|
404
|
+
return ["model"]
|
|
405
|
+
return segments[: min(2, len(segments))]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# owners with no `provenance` field of their own (schema.py forbids
|
|
409
|
+
# extras — a field is only added by a schema change) get a comment stamp instead.
|
|
410
|
+
# `Link` and `ActuatorCase` likewise have no provenance field; their changed
|
|
411
|
+
# facts receive source/witness comments rather than invalid extra schema keys.
|
|
412
|
+
# Entries are matched by PREFIX because the resolved owner includes its name
|
|
413
|
+
# or id while the family tuple stays fixed.
|
|
414
|
+
_UNSTRUCTURED_PROVENANCE_OWNERS = {
|
|
415
|
+
("topology", "battery"),
|
|
416
|
+
("model", "links"),
|
|
417
|
+
("actuator_cases",),
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _is_unstructured_owner(owner_segments: list[str]) -> bool:
|
|
422
|
+
return any(
|
|
423
|
+
tuple(owner_segments[: len(family)]) == family for family in _UNSTRUCTURED_PROVENANCE_OWNERS
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _stamp_provenance(
|
|
428
|
+
doc: Any,
|
|
429
|
+
owner_segments: list[str],
|
|
430
|
+
*,
|
|
431
|
+
field_leaf: str,
|
|
432
|
+
source: str,
|
|
433
|
+
witness: str,
|
|
434
|
+
note: str | None,
|
|
435
|
+
locked_at: dt.date,
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Stamp the owning block's provenance without erasing another fact's.
|
|
438
|
+
|
|
439
|
+
A device/rail/bus has ONE `provenance` block for every fact it carries
|
|
440
|
+
(mass, radio numbers, mount point, ...) — the honest fix is provenance
|
|
441
|
+
per fact, a schema change. Until then:
|
|
442
|
+
a fresh provenance is written plainly; an EXISTING one keeps its
|
|
443
|
+
witness/source (the facts it already covers) and gets the new fact
|
|
444
|
+
appended to `source` and `note` instead of overwritten.
|
|
445
|
+
"""
|
|
446
|
+
owner = _navigate(doc, owner_segments, create=False)
|
|
447
|
+
if not isinstance(owner, dict):
|
|
448
|
+
raise EditRefusal(f"cannot stamp provenance at {'/'.join(owner_segments)}")
|
|
449
|
+
if _is_unstructured_owner(owner_segments):
|
|
450
|
+
if owner_segments[0] == "actuator_cases" and doc.fa.flow_style():
|
|
451
|
+
# A JSON-shaped contract cannot serialize a nested YAML comment
|
|
452
|
+
# while its root/list stay flow-styled. The proposal must expose
|
|
453
|
+
# the authored source and witness in its reviewable diff.
|
|
454
|
+
doc.fa.set_block_style()
|
|
455
|
+
doc["actuator_cases"].fa.set_block_style()
|
|
456
|
+
owner.fa.set_block_style()
|
|
457
|
+
_stamp_comment_provenance(
|
|
458
|
+
owner,
|
|
459
|
+
field_leaf=field_leaf,
|
|
460
|
+
source=source,
|
|
461
|
+
witness=witness,
|
|
462
|
+
note=note,
|
|
463
|
+
locked_at=locked_at,
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
prov = owner.get("provenance")
|
|
467
|
+
stamp = f"{locked_at.isoformat()}: {field_leaf} declared, witness {witness}"
|
|
468
|
+
if prov is None:
|
|
469
|
+
prov = CommentedMap()
|
|
470
|
+
owner["provenance"] = prov
|
|
471
|
+
prov["witness"] = witness
|
|
472
|
+
prov["source"] = source
|
|
473
|
+
prov["note"] = stamp
|
|
474
|
+
else:
|
|
475
|
+
if not prov.get("witness"):
|
|
476
|
+
prov["witness"] = witness
|
|
477
|
+
old_source = prov.get("source")
|
|
478
|
+
appended = f"{field_leaf} from {source} ({locked_at.isoformat()})"
|
|
479
|
+
prov["source"] = f"{old_source}; {appended}" if old_source else source
|
|
480
|
+
old_note = prov.get("note")
|
|
481
|
+
prov["note"] = f"{old_note}\n{stamp}" if old_note else stamp
|
|
482
|
+
prov["locked_at"] = locked_at
|
|
483
|
+
if note:
|
|
484
|
+
prov["note"] = f"{prov['note']}\n{locked_at.isoformat()}: {note}"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _stamp_comment_provenance(
|
|
488
|
+
owner: Any,
|
|
489
|
+
*,
|
|
490
|
+
field_leaf: str,
|
|
491
|
+
source: str,
|
|
492
|
+
witness: str,
|
|
493
|
+
note: str | None,
|
|
494
|
+
locked_at: dt.date,
|
|
495
|
+
) -> None:
|
|
496
|
+
"""Record a fact's source/witness as a YAML comment when the owner has no
|
|
497
|
+
`provenance` field of its own (`_UNSTRUCTURED_PROVENANCE_OWNERS`).
|
|
498
|
+
|
|
499
|
+
Stamped directly above `field_leaf`'s own key inside `owner` (the block
|
|
500
|
+
that was just navigated to and already carries the newly-written field) --
|
|
501
|
+
works whether `owner` is a dict-shaped singleton reached by a fixed key
|
|
502
|
+
(`topology/battery`) or a name-addressed list entry reached by its own
|
|
503
|
+
`name` (`model/links/x500_frame`); both are plain `CommentedMap`s once
|
|
504
|
+
resolved, so the same call handles either.
|
|
505
|
+
|
|
506
|
+
`yaml_set_comment_before_after_key` is additive, not a replace — each
|
|
507
|
+
call adds one more comment line above the key, so successive stamps
|
|
508
|
+
accumulate the same way a structured provenance's `source`/`note` do.
|
|
509
|
+
Unstructured and best-effort: nothing here is read back by `check` or
|
|
510
|
+
`describe`.
|
|
511
|
+
"""
|
|
512
|
+
stamp = f"{locked_at.isoformat()}: {field_leaf} declared, witness {witness}, source {source}"
|
|
513
|
+
if note:
|
|
514
|
+
stamp += f"; {note}"
|
|
515
|
+
owner.yaml_set_comment_before_after_key(field_leaf, before=stamp, indent=2)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ---------------------------------------------------------------- change model
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ---------------------------------------------------------------- the standing mark
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _budget_limit_changes(changes: list["Change"]) -> dict[str, "Change"]:
|
|
525
|
+
"""The changes that declare a `budgets/<id>/limit`, keyed by budget id."""
|
|
526
|
+
out: dict[str, Change] = {}
|
|
527
|
+
for change in changes:
|
|
528
|
+
segments = split_path(change.path)
|
|
529
|
+
if len(segments) == 3 and segments[0] == "budgets" and segments[2] == "limit":
|
|
530
|
+
out[segments[1]] = change
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _cleared_by_raising(before: Any, after: Any) -> set[str]:
|
|
535
|
+
"""Budget ids whose limit GREW and whose violation went away with it.
|
|
536
|
+
|
|
537
|
+
The two halves are both load-bearing: a limit that grew but left the finding
|
|
538
|
+
standing is not a cleared violation, and a violation that went away while the
|
|
539
|
+
limit fell was cleared by shedding real draw, which needs no mark. Violations
|
|
540
|
+
are matched by their full contract path, so the raised-limit case that trades
|
|
541
|
+
an overload for a *different* finding (draw > limit becomes limit > rail
|
|
542
|
+
capacity) is still a cleared overload and is still marked.
|
|
543
|
+
"""
|
|
544
|
+
from lockwire.checks import check_budgets # local: checks.py imports report/scene
|
|
545
|
+
|
|
546
|
+
old_limits = {budget.id: budget.limit.base() for budget in before.budgets}
|
|
547
|
+
raised = {
|
|
548
|
+
budget.id
|
|
549
|
+
for budget in after.budgets
|
|
550
|
+
if budget.id in old_limits and budget.limit.base() > old_limits[budget.id]
|
|
551
|
+
}
|
|
552
|
+
if not raised:
|
|
553
|
+
return set()
|
|
554
|
+
gone = {
|
|
555
|
+
finding.quantity for finding in check_budgets(before) if finding.severity == "violation"
|
|
556
|
+
} - {finding.quantity for finding in check_budgets(after) if finding.severity == "violation"}
|
|
557
|
+
return {
|
|
558
|
+
budget_id
|
|
559
|
+
for budget_id in raised
|
|
560
|
+
if any(
|
|
561
|
+
quantity == f"budgets/{budget_id}" or quantity.startswith(f"budgets/{budget_id}/")
|
|
562
|
+
for quantity in gone
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _stamp_limit_raised(doc: Any, budget_id: str, *, witness: str, locked_at: dt.date) -> bool:
|
|
568
|
+
"""Append the standing mark to the budget's provenance note. No new field:
|
|
569
|
+
`Provenance.note` is a schema field and the mark is text in it."""
|
|
570
|
+
for entry in doc.get("budgets") or []:
|
|
571
|
+
if not isinstance(entry, dict) or entry.get("id") != budget_id:
|
|
572
|
+
continue
|
|
573
|
+
prov = entry.get("provenance")
|
|
574
|
+
if not isinstance(prov, dict):
|
|
575
|
+
return False
|
|
576
|
+
mark = f"{LIMIT_RAISED_MARK}{locked_at.isoformat()}, witness {witness}"
|
|
577
|
+
old_note = prov.get("note")
|
|
578
|
+
prov["note"] = f"{old_note}\n{mark}" if old_note else mark
|
|
579
|
+
return True
|
|
580
|
+
return False
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@dataclass
|
|
584
|
+
class Change:
|
|
585
|
+
path: str
|
|
586
|
+
value: Any # raw VALUE string, or an already-structured python value
|
|
587
|
+
source: str
|
|
588
|
+
witness: str
|
|
589
|
+
note: str | None = None
|
|
590
|
+
locked_at: dt.date | None = None
|
|
591
|
+
unit: str | None = None # sibling `unit:` key (lockwire scene import's change-file shape)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@dataclass
|
|
595
|
+
class EditResult:
|
|
596
|
+
diff: str
|
|
597
|
+
lines: list[str] = field(default_factory=list)
|
|
598
|
+
written: bool = False
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@dataclass
|
|
602
|
+
class EditProposal:
|
|
603
|
+
"""A validated edit that has not touched the filesystem."""
|
|
604
|
+
|
|
605
|
+
result: EditResult
|
|
606
|
+
contract: Contract
|
|
607
|
+
text: str
|
|
608
|
+
original_text: str
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _dump(doc: Any, style: tuple[int, int] = _DEFAULT_SEQUENCE_STYLE) -> str:
|
|
612
|
+
buf = io.StringIO()
|
|
613
|
+
_yaml(*style).dump(doc, buf)
|
|
614
|
+
return buf.getvalue()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _load_doc_guarded(contract_path: Path) -> tuple[str, Any, tuple[int, int]]:
|
|
618
|
+
_guard_contract_raw(contract_path)
|
|
619
|
+
return _load_doc_bytes_guarded(contract_path.read_bytes())
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _load_doc_bytes_guarded(raw_bytes: bytes) -> tuple[str, Any, tuple[int, int]]:
|
|
623
|
+
size = len(raw_bytes)
|
|
624
|
+
if size > MAX_CONTRACT_BYTES:
|
|
625
|
+
raise ValueError(f"contract file exceeds size limit ({size} bytes > {MAX_CONTRACT_BYTES})")
|
|
626
|
+
text = raw_bytes.decode("utf-8")
|
|
627
|
+
style = _sniff_sequence_style(text)
|
|
628
|
+
doc = _load_yaml_guarded(_yaml(*style), io.StringIO(text))
|
|
629
|
+
_reject_yaml_anchors_aliases(doc)
|
|
630
|
+
return text, doc, style
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _loc_to_path(doc: Any, loc: tuple[Any, ...]) -> str:
|
|
634
|
+
"""Best-effort readable path for a pydantic error location (index -> id)."""
|
|
635
|
+
node = doc
|
|
636
|
+
parts: list[str] = []
|
|
637
|
+
for seg in loc:
|
|
638
|
+
parts.append(str(seg))
|
|
639
|
+
if isinstance(node, dict):
|
|
640
|
+
node = node.get(seg)
|
|
641
|
+
elif isinstance(node, list) and isinstance(seg, int) and 0 <= seg < len(node):
|
|
642
|
+
item = node[seg]
|
|
643
|
+
if isinstance(item, dict) and "id" in item:
|
|
644
|
+
parts[-1] = str(item["id"])
|
|
645
|
+
node = item
|
|
646
|
+
else:
|
|
647
|
+
node = None
|
|
648
|
+
return "/".join(parts)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _apply_collection_append(doc: Any, index: int, change: Change, collection_key: str) -> str:
|
|
652
|
+
"""`apply` entry whose path IS a top-level collection: append each row.
|
|
653
|
+
|
|
654
|
+
`apply` could stamp a fact inside a
|
|
655
|
+
device/rail/bus, but a whole new `budgets`/`families`/`device_models` row
|
|
656
|
+
has no owning block yet to stamp provenance onto — `declare`'s generic
|
|
657
|
+
"set a leaf, stamp the parent's provenance" model does not fit "create the
|
|
658
|
+
parent". Each row gets its own provenance (kept as authored if the row
|
|
659
|
+
already carries one, e.g. from a hand-written change file; stamped from
|
|
660
|
+
this entry's --source/--witness otherwise).
|
|
661
|
+
"""
|
|
662
|
+
rows = change.value
|
|
663
|
+
if not isinstance(rows, list) or not rows:
|
|
664
|
+
raise EditRefusal(f"entry {index} ({change.path}): expected a non-empty list of entries")
|
|
665
|
+
collection = _ensure_list(doc, [collection_key])
|
|
666
|
+
locked_at = change.locked_at or dt.date.today()
|
|
667
|
+
added: list[str] = []
|
|
668
|
+
for row in rows:
|
|
669
|
+
if not isinstance(row, dict) or not row.get("id"):
|
|
670
|
+
raise EditRefusal(f"entry {index} ({change.path}): every row needs an id")
|
|
671
|
+
_refuse_duplicate(collection, row["id"], collection_key)
|
|
672
|
+
entry = CommentedMap(row) if not isinstance(row, CommentedMap) else row
|
|
673
|
+
if not entry.get("provenance"):
|
|
674
|
+
prov = CommentedMap()
|
|
675
|
+
prov["witness"] = change.witness
|
|
676
|
+
prov["source"] = change.source
|
|
677
|
+
prov["locked_at"] = locked_at
|
|
678
|
+
if change.note:
|
|
679
|
+
prov["note"] = f"{locked_at.isoformat()}: {change.note}"
|
|
680
|
+
entry["provenance"] = prov
|
|
681
|
+
collection.append(entry)
|
|
682
|
+
added.append(str(entry["id"]))
|
|
683
|
+
return f"added {collection_key} {', '.join(added)} (witness {change.witness}, source {change.source})"
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
_ASSISTANT_NAME_DENYLIST = frozenset(
|
|
687
|
+
{"claude", "gpt", "chatgpt", "codex", "copilot", "gemini", "assistant", "ai"}
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _is_inside_git_repo(path: Path) -> bool:
|
|
692
|
+
"""Walk up from ``path``'s parent looking for a ``.git`` dir or file.
|
|
693
|
+
|
|
694
|
+
No subprocess (cheaper than ``git rev-parse``); a ``.git`` file covers
|
|
695
|
+
worktrees and submodules, a ``.git`` dir covers the ordinary case.
|
|
696
|
+
"""
|
|
697
|
+
for parent in (path.resolve().parent, *path.resolve().parent.parents):
|
|
698
|
+
if (parent / ".git").exists():
|
|
699
|
+
return True
|
|
700
|
+
return False
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _backup_temp(path: Path, contents: bytes) -> Path:
|
|
704
|
+
"""Stage backup bytes beside their destination without changing it."""
|
|
705
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
|
|
706
|
+
tmp_path = Path(tmp_name)
|
|
707
|
+
try:
|
|
708
|
+
with os.fdopen(fd, "wb") as handle:
|
|
709
|
+
handle.write(contents)
|
|
710
|
+
except BaseException:
|
|
711
|
+
tmp_path.unlink(missing_ok=True)
|
|
712
|
+
raise
|
|
713
|
+
return tmp_path
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _replace_then_backup(tmp_path: Path, contract_path: Path, original_text: str) -> None:
|
|
717
|
+
"""Pair a contract replacement with its non-git undo baseline."""
|
|
718
|
+
if _is_inside_git_repo(contract_path):
|
|
719
|
+
os.replace(tmp_path, contract_path)
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
backup_path = contract_path.with_name(contract_path.name + ".bak")
|
|
723
|
+
previous_backup = backup_path.read_bytes() if backup_path.exists() else None
|
|
724
|
+
staged_backup = _backup_temp(backup_path, original_text.encode("utf-8"))
|
|
725
|
+
try:
|
|
726
|
+
# Keep the old undo bytes on disk before changing either destination.
|
|
727
|
+
restore_backup = (
|
|
728
|
+
_backup_temp(backup_path, previous_backup) if previous_backup is not None else None
|
|
729
|
+
)
|
|
730
|
+
retain_restore = False
|
|
731
|
+
try:
|
|
732
|
+
# Install a complete prospective baseline before the contract replacement.
|
|
733
|
+
# If that replacement fails, restore the exact prior backup under the same lock.
|
|
734
|
+
os.replace(staged_backup, backup_path)
|
|
735
|
+
try:
|
|
736
|
+
os.replace(tmp_path, contract_path)
|
|
737
|
+
except BaseException as replace_error:
|
|
738
|
+
try:
|
|
739
|
+
if restore_backup is None:
|
|
740
|
+
backup_path.unlink(missing_ok=True)
|
|
741
|
+
else:
|
|
742
|
+
os.replace(restore_backup, backup_path)
|
|
743
|
+
except BaseException as recovery_error:
|
|
744
|
+
# The prior backup remains a complete sibling file even
|
|
745
|
+
# when the destination cannot be restored right now.
|
|
746
|
+
if restore_backup is not None:
|
|
747
|
+
retain_restore = True
|
|
748
|
+
raise EditRecoveryError(
|
|
749
|
+
restore_backup, replace_error, recovery_error
|
|
750
|
+
) from recovery_error
|
|
751
|
+
raise EditRecoveryError(
|
|
752
|
+
backup_path,
|
|
753
|
+
replace_error,
|
|
754
|
+
recovery_error,
|
|
755
|
+
contract_state="unchanged",
|
|
756
|
+
backup_state=(
|
|
757
|
+
"the prospective .bak contains the contract's pre-write bytes"
|
|
758
|
+
),
|
|
759
|
+
recovery_action="cleanup",
|
|
760
|
+
) from recovery_error
|
|
761
|
+
raise
|
|
762
|
+
finally:
|
|
763
|
+
if restore_backup is not None and not retain_restore:
|
|
764
|
+
restore_backup.unlink(missing_ok=True)
|
|
765
|
+
finally:
|
|
766
|
+
staged_backup.unlink(missing_ok=True)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _is_valid_witness_name(witness: str) -> bool:
|
|
770
|
+
"""True for a name ``validate_witness`` would accept: non-blank, not "you",
|
|
771
|
+
not an assistant name. Shared so a config default gets the identical bar a
|
|
772
|
+
hand-typed ``--witness`` does — a bad default must fail open into
|
|
773
|
+
the existing refusal, never bypass it under the "(default)" mark.
|
|
774
|
+
"""
|
|
775
|
+
if not witness or not witness.strip():
|
|
776
|
+
return False
|
|
777
|
+
return witness.strip().lower() not in _ASSISTANT_NAME_DENYLIST | {"you"}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def validate_witness(witness: str, *, refusal: str) -> None:
|
|
781
|
+
"""Refuse a witness that is blank, the bare word "you", or an assistant name.
|
|
782
|
+
|
|
783
|
+
Shared by both write paths (``_apply_change`` and ``add_entry``) so the
|
|
784
|
+
refusal list lives in one place. Does not pick a default witness itself —
|
|
785
|
+
that is ``resolve_witness``'s job; this still refuses a blank/"you"
|
|
786
|
+
string whether it arrived as-is or as an unmarked default.
|
|
787
|
+
"""
|
|
788
|
+
if not _is_valid_witness_name(witness):
|
|
789
|
+
raise EditRefusal(refusal)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
_WITNESS_CONFIG_ENV = "LOCKWIRE_WITNESS_CONFIG"
|
|
793
|
+
_WITNESS_DEFAULT_MARK = " (default)"
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def default_witness_config_path() -> Path:
|
|
797
|
+
"""Where the per-machine witness default lives.
|
|
798
|
+
|
|
799
|
+
``LOCKWIRE_WITNESS_CONFIG`` overrides the path outright — tests use this so
|
|
800
|
+
a run never reads or depends on a real file of the user's (a test never
|
|
801
|
+
touches a real file by default). Otherwise the app
|
|
802
|
+
and CLI share one XDG-style location.
|
|
803
|
+
"""
|
|
804
|
+
override = os.environ.get(_WITNESS_CONFIG_ENV)
|
|
805
|
+
if override:
|
|
806
|
+
return Path(override)
|
|
807
|
+
config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
808
|
+
return Path(config_home) / "lockwire" / "config.toml"
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def read_default_witness() -> str | None:
|
|
812
|
+
"""The per-machine config's ``witness`` value, or ``None`` if unset.
|
|
813
|
+
|
|
814
|
+
Missing file, missing key, a blank value, or a file that fails to parse
|
|
815
|
+
all read the same way: no default, so the caller falls back to the
|
|
816
|
+
existing refusal. A default mechanism failing open into "no default" can
|
|
817
|
+
never itself cause a bad write; it can only cause the same refusal that
|
|
818
|
+
already existed before the default.
|
|
819
|
+
"""
|
|
820
|
+
path = default_witness_config_path()
|
|
821
|
+
if not path.is_file():
|
|
822
|
+
return None
|
|
823
|
+
try:
|
|
824
|
+
import tomllib
|
|
825
|
+
|
|
826
|
+
with path.open("rb") as handle:
|
|
827
|
+
data = tomllib.load(handle)
|
|
828
|
+
except (OSError, ValueError):
|
|
829
|
+
return None
|
|
830
|
+
value = data.get("witness")
|
|
831
|
+
if not isinstance(value, str) or not value.strip():
|
|
832
|
+
return None
|
|
833
|
+
return value.strip()
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def resolve_witness(witness: str | None) -> str:
|
|
837
|
+
"""An explicit ``--witness`` wins; an absent/blank one falls back to the
|
|
838
|
+
per-machine config default, marked "(default)".
|
|
839
|
+
|
|
840
|
+
The mark is written straight into the value that ends up stored — the
|
|
841
|
+
same string lands in the file's ``witness:`` field, in ``describe``'s
|
|
842
|
+
per-device page, and (once merged) in ``blame``'s row for this record —
|
|
843
|
+
so a defaulted witness can never render identically to a hand-entered
|
|
844
|
+
one on any surface that just echoes ``provenance.witness``. Does not
|
|
845
|
+
itself refuse: a resolved value that is still blank, or a config value
|
|
846
|
+
that is itself blank/"you"/an assistant name, reaches ``validate_witness``
|
|
847
|
+
unmarked and is refused exactly as before.
|
|
848
|
+
|
|
849
|
+
Only OMISSION (``witness is None``) taking the default. An
|
|
850
|
+
explicit blank — ``--witness ""`` or all-whitespace — is a different act:
|
|
851
|
+
the caller deliberately passed nothing, so silently attributing a
|
|
852
|
+
default witness to them would be wrong even though the string looks the
|
|
853
|
+
same as an omission once ``.strip``-ed. That case is refused here,
|
|
854
|
+
before the default is ever consulted, with the same wording
|
|
855
|
+
used for a hand-typed blank/"you"/assistant name.
|
|
856
|
+
"""
|
|
857
|
+
if witness is not None and not witness.strip():
|
|
858
|
+
raise EditRefusal(f"--witness must name a real person, not {witness!r}")
|
|
859
|
+
if witness and witness.strip():
|
|
860
|
+
return witness
|
|
861
|
+
default = read_default_witness()
|
|
862
|
+
if default and _is_valid_witness_name(default):
|
|
863
|
+
return f"{default}{_WITNESS_DEFAULT_MARK}"
|
|
864
|
+
return witness or ""
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
_URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]+:")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def source_is_absolute(source: str) -> bool:
|
|
871
|
+
"""True when ``source`` names a location only one machine has.
|
|
872
|
+
|
|
873
|
+
Lexical, no filesystem touched: a POSIX absolute path, a ``~`` home path, a
|
|
874
|
+
backslash-rooted or UNC Windows path, or a drive path (``C:``, ``C:/``,
|
|
875
|
+
``C:\\``). A URL is *not* absolute in this sense — it travels with the
|
|
876
|
+
contract — and the scheme length is what separates the two: ``https:`` is a
|
|
877
|
+
URL, ``C:`` is a drive letter.
|
|
878
|
+
"""
|
|
879
|
+
text = source.strip()
|
|
880
|
+
if text.startswith(("/", "~", "\\")):
|
|
881
|
+
return True
|
|
882
|
+
return len(text) >= 2 and text[0].isalpha() and text[1] == ":" and text[2:3] in ("", "/", "\\")
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def validate_source(source: str, *, prefix: str = "") -> None:
|
|
886
|
+
"""Refuse a blank source, and refuse an absolute path.
|
|
887
|
+
|
|
888
|
+
Shared by every write door — ``_apply_change`` (for ``--source`` and, via
|
|
889
|
+
``iter_written_sources``, for every source the change's *value* lands),
|
|
890
|
+
``add_entry`` (which every ``_build_*`` and ``_add_battery_entry`` runs
|
|
891
|
+
through) and ``scene_import.write_change_file`` — so the rule lives in one
|
|
892
|
+
place, the way ``validate_witness`` does. A source that survives this is relative, and a
|
|
893
|
+
relative source resolves from the contract's own directory
|
|
894
|
+
(``resolve_source``), so the evidence travels with the contract.
|
|
895
|
+
"""
|
|
896
|
+
if not source or not source.strip():
|
|
897
|
+
raise EditRefusal(f"{prefix}--source is required")
|
|
898
|
+
if source_is_absolute(source):
|
|
899
|
+
raise EditRefusal(
|
|
900
|
+
f"{prefix}--source must be a path relative to the contract, a URL or a cited "
|
|
901
|
+
f"title, not the absolute path {source.strip()!r} — an absolute path names a "
|
|
902
|
+
f"file only this machine has, so the evidence cannot travel with the "
|
|
903
|
+
f"contract"
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def iter_written_sources(path: str, value: Any) -> Iterator[tuple[str, str]]:
|
|
908
|
+
"""Every source string a change would land, with the path it lands at.
|
|
909
|
+
|
|
910
|
+
``--source`` is the witness's citation for the edit; this is the other half —
|
|
911
|
+
the value side. ``declare devices/x/provenance/source /Users/me/ds.pdf`` and an
|
|
912
|
+
appended row carrying its own ``provenance.source`` both write a source field
|
|
913
|
+
without ever touching ``--source``, so both are walked here and validated by the
|
|
914
|
+
same guard.
|
|
915
|
+
"""
|
|
916
|
+
if isinstance(value, str):
|
|
917
|
+
if path.rsplit("/", 1)[-1] == "source":
|
|
918
|
+
yield path, value
|
|
919
|
+
elif isinstance(value, dict):
|
|
920
|
+
for key, item in value.items():
|
|
921
|
+
yield from iter_written_sources(f"{path}/{key}", item)
|
|
922
|
+
elif isinstance(value, (list, tuple)):
|
|
923
|
+
for item in value:
|
|
924
|
+
yield from iter_written_sources(path, item)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def relative_source(contract_path: Path, file_path: Path) -> str:
|
|
928
|
+
"""The portable spelling of a file a write path was handed as a path.
|
|
929
|
+
|
|
930
|
+
``lockwire start /home/me/robot.urdf`` must not stamp ``/home/me`` into the
|
|
931
|
+
contract — nobody else has that directory. The path is re-read from the
|
|
932
|
+
contract's own directory instead, so the model file travels with the
|
|
933
|
+
contract. Falls back to the bare filename when no relative spelling exists
|
|
934
|
+
(a different Windows drive).
|
|
935
|
+
"""
|
|
936
|
+
try:
|
|
937
|
+
return os.path.relpath(file_path.resolve(), contract_path.resolve().parent)
|
|
938
|
+
except ValueError:
|
|
939
|
+
return file_path.name
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def resolve_source(contract_path: Path, source: str) -> Path | None:
|
|
943
|
+
"""Where a relative file source points, read from the contract's own directory.
|
|
944
|
+
|
|
945
|
+
A source is a file beside the contract, a URL or a cited title. Only
|
|
946
|
+
the first kind resolves — a URL, a cited title and (refused on write, but
|
|
947
|
+
still found in older contracts) an absolute path return ``None``. The
|
|
948
|
+
contract's directory is the anchor, never the working directory, so the same
|
|
949
|
+
contract resolves the same way from anywhere.
|
|
950
|
+
"""
|
|
951
|
+
text = source.strip()
|
|
952
|
+
if not text or source_is_absolute(text) or _URL_SCHEME.match(text) or " " in text:
|
|
953
|
+
return None
|
|
954
|
+
return contract_path.resolve().parent / text
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _apply_change(doc: Any, index: int, change: Change) -> str:
|
|
958
|
+
validate_source(change.source, prefix=f"entry {index} ({change.path}): ")
|
|
959
|
+
for where, written in iter_written_sources(change.path, change.value):
|
|
960
|
+
validate_source(written, prefix=f"entry {index} ({where}): ")
|
|
961
|
+
validate_witness(
|
|
962
|
+
change.witness,
|
|
963
|
+
refusal=f"entry {index} ({change.path}): --witness must name a real person, not {change.witness!r}",
|
|
964
|
+
)
|
|
965
|
+
segments = split_path(change.path)
|
|
966
|
+
if len(segments) == 1 and segments[0] in _COLLECTION_APPEND_KINDS:
|
|
967
|
+
return _apply_collection_append(doc, index, change, segments[0])
|
|
968
|
+
try:
|
|
969
|
+
parent = _navigate(doc, segments[:-1], create=True)
|
|
970
|
+
except EditRefusal as error:
|
|
971
|
+
raise EditRefusal(f"entry {index} ({change.path}): {error}") from error
|
|
972
|
+
if not isinstance(parent, dict):
|
|
973
|
+
raise EditRefusal(f"entry {index} ({change.path}): not a container")
|
|
974
|
+
key = segments[-1]
|
|
975
|
+
try:
|
|
976
|
+
value = (
|
|
977
|
+
parse_value(change.value, field_name=key)
|
|
978
|
+
if isinstance(change.value, str)
|
|
979
|
+
else change.value
|
|
980
|
+
)
|
|
981
|
+
if change.unit is not None:
|
|
982
|
+
value = _attach_unit(value, change.unit)
|
|
983
|
+
except EditRefusal as error:
|
|
984
|
+
raise EditRefusal(f"entry {index} ({change.path}): {error}") from error
|
|
985
|
+
parent[key] = value
|
|
986
|
+
locked_at = change.locked_at or dt.date.today()
|
|
987
|
+
owner_segments = provenance_owner_path(segments)
|
|
988
|
+
try:
|
|
989
|
+
_stamp_provenance(
|
|
990
|
+
doc,
|
|
991
|
+
owner_segments,
|
|
992
|
+
field_leaf=key,
|
|
993
|
+
source=change.source,
|
|
994
|
+
witness=change.witness,
|
|
995
|
+
note=change.note,
|
|
996
|
+
locked_at=locked_at,
|
|
997
|
+
)
|
|
998
|
+
except EditRefusal as error:
|
|
999
|
+
raise EditRefusal(f"entry {index} ({change.path}): {error}") from error
|
|
1000
|
+
return f"declared {change.path} = {format_value(value)} (witness {change.witness}, source {change.source})"
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def propose_changes_from_bytes(
|
|
1004
|
+
contract_path: Path, raw_bytes: bytes, changes: list[Change]
|
|
1005
|
+
) -> EditProposal:
|
|
1006
|
+
"""Build a proposal from caller-bound raw bytes without reading the path."""
|
|
1007
|
+
if not changes:
|
|
1008
|
+
raise EditRefusal("no changes given")
|
|
1009
|
+
original_text, doc, style = _load_doc_bytes_guarded(raw_bytes)
|
|
1010
|
+
|
|
1011
|
+
# a declare that clears a violation by RAISING the limit rather
|
|
1012
|
+
# than lowering the draw leaves a standing mark. Reading the contract as it
|
|
1013
|
+
# stands is the only way to know which side moved; a file that does not
|
|
1014
|
+
# validate today has no violations to clear, so no mark can be owed.
|
|
1015
|
+
limit_changes = _budget_limit_changes(changes)
|
|
1016
|
+
before: Any | None = None
|
|
1017
|
+
if limit_changes:
|
|
1018
|
+
try:
|
|
1019
|
+
declared = doc.get("schema_version", SCHEMA_VERSION) if isinstance(doc, dict) else None
|
|
1020
|
+
if declared not in SUPPORTED_SCHEMA_VERSIONS:
|
|
1021
|
+
raise UnsupportedSchemaVersion(declared, SUPPORTED_SCHEMA_VERSIONS)
|
|
1022
|
+
before = Contract.model_validate(doc)
|
|
1023
|
+
except Exception: # noqa: BLE001 — an unloadable "before" owes no mark
|
|
1024
|
+
before = None
|
|
1025
|
+
|
|
1026
|
+
lines: list[str] = []
|
|
1027
|
+
for index, change in enumerate(changes, start=1):
|
|
1028
|
+
lines.append(_apply_change(doc, index, change))
|
|
1029
|
+
|
|
1030
|
+
new_text = _dump(doc, style)
|
|
1031
|
+
# The file already declared an unsupported ``schema_version`` before this
|
|
1032
|
+
# edit touched it, so the edit is not the cause: let the typed refusal
|
|
1033
|
+
# through as itself rather than letting the correlator blame an entry.
|
|
1034
|
+
try:
|
|
1035
|
+
declared = doc.get("schema_version", SCHEMA_VERSION) if isinstance(doc, dict) else None
|
|
1036
|
+
if declared not in SUPPORTED_SCHEMA_VERSIONS:
|
|
1037
|
+
raise UnsupportedSchemaVersion(declared, SUPPORTED_SCHEMA_VERSIONS)
|
|
1038
|
+
after = Contract.model_validate(doc)
|
|
1039
|
+
except UnsupportedSchemaVersion:
|
|
1040
|
+
raise
|
|
1041
|
+
except Exception as error:
|
|
1042
|
+
culprit = _correlate_failure(doc, changes, error)
|
|
1043
|
+
raise EditRefusal(culprit) from error
|
|
1044
|
+
|
|
1045
|
+
if before is not None:
|
|
1046
|
+
marked = False
|
|
1047
|
+
for budget_id in sorted(_cleared_by_raising(before, after)):
|
|
1048
|
+
change = limit_changes[budget_id]
|
|
1049
|
+
if _stamp_limit_raised(
|
|
1050
|
+
doc,
|
|
1051
|
+
budget_id,
|
|
1052
|
+
witness=change.witness,
|
|
1053
|
+
locked_at=change.locked_at or dt.date.today(),
|
|
1054
|
+
):
|
|
1055
|
+
marked = True
|
|
1056
|
+
lines.append(f"marked budgets/{budget_id}: the limit was raised past a violation")
|
|
1057
|
+
if marked:
|
|
1058
|
+
new_text = _dump(doc, style)
|
|
1059
|
+
after = Contract.model_validate(doc)
|
|
1060
|
+
|
|
1061
|
+
diff = "".join(
|
|
1062
|
+
difflib.unified_diff(
|
|
1063
|
+
original_text.splitlines(keepends=True),
|
|
1064
|
+
new_text.splitlines(keepends=True),
|
|
1065
|
+
fromfile=str(contract_path),
|
|
1066
|
+
tofile=str(contract_path),
|
|
1067
|
+
)
|
|
1068
|
+
)
|
|
1069
|
+
return EditProposal(EditResult(diff=diff, lines=lines), after, new_text, original_text)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def propose_changes(contract_path: Path, changes: list[Change]) -> EditProposal:
|
|
1073
|
+
"""Build and validate an edit proposal without creating any files."""
|
|
1074
|
+
_guard_contract_raw(contract_path)
|
|
1075
|
+
return propose_changes_from_bytes(contract_path, contract_path.read_bytes(), changes)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@_locked_write
|
|
1079
|
+
def apply_changes(
|
|
1080
|
+
contract_path: Path,
|
|
1081
|
+
changes: list[Change],
|
|
1082
|
+
*,
|
|
1083
|
+
dry_run: bool = False,
|
|
1084
|
+
expected_revision: str | None = None,
|
|
1085
|
+
) -> EditResult:
|
|
1086
|
+
"""Apply every change to the contract, validate once, write atomically.
|
|
1087
|
+
|
|
1088
|
+
Nothing is written when any entry is malformed or the resulting document
|
|
1089
|
+
fails to validate — the raised ``EditRefusal`` names the failing entry.
|
|
1090
|
+
"""
|
|
1091
|
+
proposal = propose_changes(contract_path, changes)
|
|
1092
|
+
if dry_run:
|
|
1093
|
+
return proposal.result
|
|
1094
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
1095
|
+
prefix=f".{contract_path.name}.", suffix=".tmp", dir=str(contract_path.parent)
|
|
1096
|
+
)
|
|
1097
|
+
tmp_path = Path(tmp_name)
|
|
1098
|
+
try:
|
|
1099
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
1100
|
+
handle.write(proposal.text)
|
|
1101
|
+
_replace_then_backup(tmp_path, contract_path, proposal.original_text)
|
|
1102
|
+
proposal.result.written = True
|
|
1103
|
+
return proposal.result
|
|
1104
|
+
finally:
|
|
1105
|
+
tmp_path.unlink(missing_ok=True)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
@_locked_write
|
|
1109
|
+
def apply_review(
|
|
1110
|
+
contract_path: Path,
|
|
1111
|
+
path: str,
|
|
1112
|
+
state: str,
|
|
1113
|
+
*,
|
|
1114
|
+
witness: str,
|
|
1115
|
+
note: str | None = None,
|
|
1116
|
+
locked_at: dt.date | None = None,
|
|
1117
|
+
dry_run: bool = False,
|
|
1118
|
+
expected_revision: str | None = None,
|
|
1119
|
+
) -> EditResult:
|
|
1120
|
+
"""Approve or question one already-declared value, appending a review note
|
|
1121
|
+
to the owning block's provenance, atomically.
|
|
1122
|
+
|
|
1123
|
+
Same load -> mutate -> validate -> write shape as ``apply_changes`` — one
|
|
1124
|
+
writer, not a second one. The witness-blank/assistant-name refusal
|
|
1125
|
+
and the "questioned needs a note" refusal are not duplicated here: they
|
|
1126
|
+
are ``schema.ReviewNote``'s own validators, reached through the reload
|
|
1127
|
+
below exactly the way ``apply_changes`` reaches ``Contract``'s. That is
|
|
1128
|
+
the one gate every review door (CLI, MCP, library) shares.
|
|
1129
|
+
"""
|
|
1130
|
+
original_text, doc, style = _load_doc_guarded(contract_path)
|
|
1131
|
+
segments = split_path(path)
|
|
1132
|
+
owner_segments = provenance_owner_path(segments)
|
|
1133
|
+
owner = _navigate(doc, owner_segments, create=False)
|
|
1134
|
+
if not isinstance(owner, dict) or not isinstance(owner.get("provenance"), dict):
|
|
1135
|
+
raise EditRefusal(f"cannot review {path}: nothing declared there yet (no provenance)")
|
|
1136
|
+
prov = owner["provenance"]
|
|
1137
|
+
entry = CommentedMap()
|
|
1138
|
+
entry["state"] = state
|
|
1139
|
+
entry["witness"] = witness
|
|
1140
|
+
entry["at"] = locked_at or dt.date.today()
|
|
1141
|
+
if note:
|
|
1142
|
+
entry["note"] = note
|
|
1143
|
+
reviews = prov.get("reviews")
|
|
1144
|
+
if reviews is None:
|
|
1145
|
+
reviews = []
|
|
1146
|
+
prov["reviews"] = reviews
|
|
1147
|
+
reviews.append(entry)
|
|
1148
|
+
|
|
1149
|
+
new_text = _dump(doc, style)
|
|
1150
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
1151
|
+
prefix=f".{contract_path.name}.", suffix=".tmp", dir=str(contract_path.parent)
|
|
1152
|
+
)
|
|
1153
|
+
tmp_path = Path(tmp_name)
|
|
1154
|
+
try:
|
|
1155
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
1156
|
+
handle.write(new_text)
|
|
1157
|
+
try:
|
|
1158
|
+
load_contract(tmp_path)
|
|
1159
|
+
except UnsupportedSchemaVersion:
|
|
1160
|
+
raise
|
|
1161
|
+
except Exception as error:
|
|
1162
|
+
raise EditRefusal(f"review of {path}: {error}") from error
|
|
1163
|
+
diff = "".join(
|
|
1164
|
+
difflib.unified_diff(
|
|
1165
|
+
original_text.splitlines(keepends=True),
|
|
1166
|
+
new_text.splitlines(keepends=True),
|
|
1167
|
+
fromfile=str(contract_path),
|
|
1168
|
+
tofile=str(contract_path),
|
|
1169
|
+
)
|
|
1170
|
+
)
|
|
1171
|
+
line = f"reviewed {path}: {state} (witness {witness})"
|
|
1172
|
+
if dry_run:
|
|
1173
|
+
return EditResult(diff=diff, lines=[line], written=False)
|
|
1174
|
+
_replace_then_backup(tmp_path, contract_path, original_text)
|
|
1175
|
+
return EditResult(diff=diff, lines=[line], written=True)
|
|
1176
|
+
finally:
|
|
1177
|
+
tmp_path.unlink(missing_ok=True)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _correlate_failure(doc: Any, changes: list[Change], error: Exception) -> str:
|
|
1181
|
+
"""Turn a validation failure into "entry N (path): message" when possible."""
|
|
1182
|
+
errors = getattr(error, "errors", None)
|
|
1183
|
+
if callable(errors):
|
|
1184
|
+
for pydantic_error in errors():
|
|
1185
|
+
loc_path = _loc_to_path(doc, pydantic_error.get("loc", ()))
|
|
1186
|
+
for index, change in enumerate(changes, start=1):
|
|
1187
|
+
if (
|
|
1188
|
+
loc_path == change.path
|
|
1189
|
+
or loc_path.startswith(change.path + "/")
|
|
1190
|
+
or (change.path.startswith(loc_path + "/") if loc_path else False)
|
|
1191
|
+
):
|
|
1192
|
+
return f"entry {index} ({change.path}): {pydantic_error.get('msg', error)}"
|
|
1193
|
+
return f"contract invalid after {len(changes)} change(s): {error}"
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
# ------------------------------------------------- add device|rail|bus|family|model|budget|gearbox
|
|
1197
|
+
|
|
1198
|
+
_ADD_KINDS = ("device", "rail", "bus", "family", "model", "budget", "battery", "gearbox")
|
|
1199
|
+
_COLLECTION_APPEND_KINDS = ("families", "device_models", "budgets")
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
@_locked_write
|
|
1203
|
+
def add_entry(
|
|
1204
|
+
contract_path: Path,
|
|
1205
|
+
kind: str,
|
|
1206
|
+
fields: dict[str, Any],
|
|
1207
|
+
*,
|
|
1208
|
+
source: str,
|
|
1209
|
+
witness: str,
|
|
1210
|
+
note: str | None = None,
|
|
1211
|
+
locked_at: dt.date | None = None,
|
|
1212
|
+
dry_run: bool = False,
|
|
1213
|
+
expected_revision: str | None = None,
|
|
1214
|
+
) -> EditResult:
|
|
1215
|
+
"""Create a new device/rail/bus block, with provenance, atomically."""
|
|
1216
|
+
if kind not in _ADD_KINDS:
|
|
1217
|
+
raise EditRefusal(f"unknown add kind {kind!r}; expected one of {_ADD_KINDS}")
|
|
1218
|
+
validate_source(source)
|
|
1219
|
+
validate_witness(witness, refusal=f"--witness must name a real person, not {witness!r}")
|
|
1220
|
+
|
|
1221
|
+
original_text, doc, style = _load_doc_guarded(contract_path)
|
|
1222
|
+
locked_at = locked_at or dt.date.today()
|
|
1223
|
+
|
|
1224
|
+
if kind == "battery":
|
|
1225
|
+
return _add_battery_entry(
|
|
1226
|
+
contract_path,
|
|
1227
|
+
doc,
|
|
1228
|
+
fields,
|
|
1229
|
+
original_text=original_text,
|
|
1230
|
+
style=style,
|
|
1231
|
+
source=source,
|
|
1232
|
+
witness=witness,
|
|
1233
|
+
note=note,
|
|
1234
|
+
locked_at=locked_at,
|
|
1235
|
+
dry_run=dry_run,
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
new_id = fields.get("id")
|
|
1239
|
+
if not new_id:
|
|
1240
|
+
raise EditRefusal("--id is required")
|
|
1241
|
+
|
|
1242
|
+
# "one datasheet, one submit" — a device whose --family/--model
|
|
1243
|
+
# name records that do not exist yet gets them created alongside it, in
|
|
1244
|
+
# this same in-memory doc, so the eventual disk write is one transaction
|
|
1245
|
+
# (nothing lands unless the whole thing validates — see the tail below).
|
|
1246
|
+
# A device whose family/model already exist behaves exactly as before.
|
|
1247
|
+
created_with_device: list[tuple[str, CommentedMap]] = []
|
|
1248
|
+
if kind == "device":
|
|
1249
|
+
family_ref = fields.get("family")
|
|
1250
|
+
model_ref = fields.get("model")
|
|
1251
|
+
if model_ref and family_ref:
|
|
1252
|
+
existing_model = next(
|
|
1253
|
+
(
|
|
1254
|
+
m
|
|
1255
|
+
for m in doc.get("device_models", []) or []
|
|
1256
|
+
if isinstance(m, dict) and m.get("id") == model_ref
|
|
1257
|
+
),
|
|
1258
|
+
None,
|
|
1259
|
+
)
|
|
1260
|
+
if existing_model is not None and existing_model.get("family") != family_ref:
|
|
1261
|
+
raise EditRefusal(
|
|
1262
|
+
f"model {model_ref!r} belongs to family {existing_model.get('family')!r}, "
|
|
1263
|
+
f"not {family_ref!r}"
|
|
1264
|
+
)
|
|
1265
|
+
families = _ensure_list(doc, ["families"])
|
|
1266
|
+
if not any(isinstance(f, dict) and f.get("id") == family_ref for f in families):
|
|
1267
|
+
family_entry = _build_family(
|
|
1268
|
+
{
|
|
1269
|
+
"id": family_ref,
|
|
1270
|
+
"name": fields.get("name") or _default_family_name(family_ref),
|
|
1271
|
+
}
|
|
1272
|
+
)
|
|
1273
|
+
families.append(family_entry)
|
|
1274
|
+
created_with_device.append(("family", family_entry))
|
|
1275
|
+
models = _ensure_list(doc, ["device_models"])
|
|
1276
|
+
if not any(isinstance(m, dict) and m.get("id") == model_ref for m in models):
|
|
1277
|
+
model_entry = _build_model({"id": model_ref, "family": family_ref}, doc)
|
|
1278
|
+
models.append(model_entry)
|
|
1279
|
+
created_with_device.append(("model", model_entry))
|
|
1280
|
+
collection = _ensure_list(doc, ["devices"])
|
|
1281
|
+
_refuse_duplicate(collection, new_id, "device")
|
|
1282
|
+
entry = _build_device(fields)
|
|
1283
|
+
elif kind in ("rail", "bus"):
|
|
1284
|
+
list_key = "rails" if kind == "rail" else "buses"
|
|
1285
|
+
collection = _ensure_list(doc, ["topology", list_key])
|
|
1286
|
+
_refuse_duplicate(collection, new_id, kind)
|
|
1287
|
+
entry = _build_rail(fields) if kind == "rail" else _build_bus(fields)
|
|
1288
|
+
elif kind == "family":
|
|
1289
|
+
collection = _ensure_list(doc, ["families"])
|
|
1290
|
+
_refuse_duplicate(collection, new_id, "family")
|
|
1291
|
+
entry = _build_family(fields)
|
|
1292
|
+
elif kind == "model":
|
|
1293
|
+
collection = _ensure_list(doc, ["device_models"])
|
|
1294
|
+
_refuse_duplicate(collection, new_id, "model")
|
|
1295
|
+
entry = _build_model(fields, doc)
|
|
1296
|
+
elif kind == "gearbox":
|
|
1297
|
+
collection = _ensure_list(doc, ["gearboxes"])
|
|
1298
|
+
_refuse_duplicate(collection, new_id, "gearbox")
|
|
1299
|
+
entry = _build_gearbox(fields, doc)
|
|
1300
|
+
else: # budget
|
|
1301
|
+
collection = _ensure_list(doc, ["budgets"])
|
|
1302
|
+
_refuse_duplicate(collection, new_id, "budget")
|
|
1303
|
+
entry = _build_budget(fields, doc)
|
|
1304
|
+
|
|
1305
|
+
if kind == "device":
|
|
1306
|
+
rail_ref = fields.get("power_rail")
|
|
1307
|
+
if rail_ref is not None:
|
|
1308
|
+
rails = (
|
|
1309
|
+
doc.get("topology", {}).get("rails", [])
|
|
1310
|
+
if isinstance(doc.get("topology"), dict)
|
|
1311
|
+
else []
|
|
1312
|
+
)
|
|
1313
|
+
if not any(isinstance(r, dict) and r.get("id") == rail_ref for r in rails):
|
|
1314
|
+
raise EditRefusal(f"device references unknown rail {rail_ref!r}")
|
|
1315
|
+
bus_ref = fields.get("identity_bus_ref")
|
|
1316
|
+
if bus_ref is not None:
|
|
1317
|
+
buses = (
|
|
1318
|
+
doc.get("topology", {}).get("buses", [])
|
|
1319
|
+
if isinstance(doc.get("topology"), dict)
|
|
1320
|
+
else []
|
|
1321
|
+
)
|
|
1322
|
+
if not any(isinstance(b, dict) and b.get("id") == bus_ref for b in buses):
|
|
1323
|
+
raise EditRefusal(f"device references unknown bus {bus_ref!r}")
|
|
1324
|
+
model_ref = fields.get("model")
|
|
1325
|
+
models = doc.get("device_models", []) or []
|
|
1326
|
+
if not any(isinstance(m, dict) and m.get("id") == model_ref for m in models):
|
|
1327
|
+
existing = ", ".join(
|
|
1328
|
+
sorted(m["id"] for m in models if isinstance(m, dict) and m.get("id"))
|
|
1329
|
+
)
|
|
1330
|
+
raise EditRefusal(
|
|
1331
|
+
f"model {model_ref!r} is not declared — declare it first: "
|
|
1332
|
+
f"lockwire add model {contract_path} --id {model_ref} --family <fam.id> "
|
|
1333
|
+
"--source ... --witness ... "
|
|
1334
|
+
f"(or use an existing id: {existing or 'none declared yet'})"
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
# One shared stamper for every record this call writes ("one
|
|
1338
|
+
# witness line" for the three records) — a family/model created *because*
|
|
1339
|
+
# this device named them is not itself witnessed a second time; its
|
|
1340
|
+
# provenance points at the device that brought it in instead of repeating
|
|
1341
|
+
# the name. A family/model that already existed is untouched, unstamped.
|
|
1342
|
+
lines: list[str] = []
|
|
1343
|
+
for supporting_kind, supporting_entry in created_with_device:
|
|
1344
|
+
pointer = f"see devices/{new_id} (added together)"
|
|
1345
|
+
_stamp_add_provenance(supporting_entry, source=source, witness=pointer, locked_at=locked_at)
|
|
1346
|
+
lines.append(
|
|
1347
|
+
f"added {supporting_kind} {supporting_entry['id']} (witness {pointer}, source {source})"
|
|
1348
|
+
)
|
|
1349
|
+
_stamp_add_provenance(entry, source=source, witness=witness, locked_at=locked_at, note=note)
|
|
1350
|
+
collection.append(entry)
|
|
1351
|
+
lines.append(f"added {kind} {new_id} (witness {witness}, source {source})")
|
|
1352
|
+
|
|
1353
|
+
new_text = _dump(doc, style)
|
|
1354
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
1355
|
+
prefix=f".{contract_path.name}.", suffix=".tmp", dir=str(contract_path.parent)
|
|
1356
|
+
)
|
|
1357
|
+
tmp_path = Path(tmp_name)
|
|
1358
|
+
try:
|
|
1359
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
1360
|
+
handle.write(new_text)
|
|
1361
|
+
try:
|
|
1362
|
+
load_contract(tmp_path)
|
|
1363
|
+
except UnsupportedSchemaVersion: # in the file before the edit; see apply_changes
|
|
1364
|
+
raise
|
|
1365
|
+
except Exception as error:
|
|
1366
|
+
raise EditRefusal(f"add {kind} {new_id!r} invalid: {error}") from error
|
|
1367
|
+
|
|
1368
|
+
diff = "".join(
|
|
1369
|
+
difflib.unified_diff(
|
|
1370
|
+
original_text.splitlines(keepends=True),
|
|
1371
|
+
new_text.splitlines(keepends=True),
|
|
1372
|
+
fromfile=str(contract_path),
|
|
1373
|
+
tofile=str(contract_path),
|
|
1374
|
+
)
|
|
1375
|
+
)
|
|
1376
|
+
if dry_run:
|
|
1377
|
+
return EditResult(diff=diff, lines=lines, written=False)
|
|
1378
|
+
_replace_then_backup(tmp_path, contract_path, original_text)
|
|
1379
|
+
return EditResult(diff=diff, lines=lines, written=True)
|
|
1380
|
+
finally:
|
|
1381
|
+
tmp_path.unlink(missing_ok=True)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _add_battery_entry(
|
|
1385
|
+
contract_path: Path,
|
|
1386
|
+
doc: Any,
|
|
1387
|
+
fields: dict[str, Any],
|
|
1388
|
+
*,
|
|
1389
|
+
original_text: str,
|
|
1390
|
+
style: tuple[int, int],
|
|
1391
|
+
source: str,
|
|
1392
|
+
witness: str,
|
|
1393
|
+
note: str | None,
|
|
1394
|
+
locked_at: dt.date,
|
|
1395
|
+
dry_run: bool,
|
|
1396
|
+
) -> EditResult:
|
|
1397
|
+
"""Create or update `topology/battery` — a singleton, not an id'd collection.
|
|
1398
|
+
|
|
1399
|
+
`Battery` (schema.py) has no `provenance` field and forbids extras
|
|
1400
|
+
(schema fields are added by a schema change) — source/witness/chemistry
|
|
1401
|
+
are recorded as a YAML comment on the `battery:` key instead of a
|
|
1402
|
+
structured block. Unstructured: `check`/`describe` never read it back.
|
|
1403
|
+
"""
|
|
1404
|
+
capacity = fields.get("capacity")
|
|
1405
|
+
if not capacity:
|
|
1406
|
+
raise EditRefusal("battery requires --capacity")
|
|
1407
|
+
topology = _navigate(doc, ["topology"], create=True)
|
|
1408
|
+
battery = topology.get("battery")
|
|
1409
|
+
if not isinstance(battery, dict):
|
|
1410
|
+
battery = CommentedMap()
|
|
1411
|
+
topology["battery"] = battery
|
|
1412
|
+
battery["capacity"] = parse_value(capacity, field_name="capacity")
|
|
1413
|
+
if fields.get("nominal_voltage"):
|
|
1414
|
+
battery["voltage"] = parse_value(fields["nominal_voltage"], field_name="voltage")
|
|
1415
|
+
|
|
1416
|
+
stamp = f"{locked_at.isoformat()}: battery declared, witness {witness}, source {source}"
|
|
1417
|
+
if fields.get("chemistry"):
|
|
1418
|
+
stamp += f"; chemistry {fields['chemistry']}"
|
|
1419
|
+
if note:
|
|
1420
|
+
stamp += f"; {note}"
|
|
1421
|
+
topology.yaml_set_comment_before_after_key("battery", before=stamp, indent=2)
|
|
1422
|
+
|
|
1423
|
+
new_text = _dump(doc, style)
|
|
1424
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
1425
|
+
prefix=f".{contract_path.name}.", suffix=".tmp", dir=str(contract_path.parent)
|
|
1426
|
+
)
|
|
1427
|
+
tmp_path = Path(tmp_name)
|
|
1428
|
+
try:
|
|
1429
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
1430
|
+
handle.write(new_text)
|
|
1431
|
+
try:
|
|
1432
|
+
load_contract(tmp_path)
|
|
1433
|
+
except UnsupportedSchemaVersion: # in the file before the edit; see apply_changes
|
|
1434
|
+
raise
|
|
1435
|
+
except Exception as error:
|
|
1436
|
+
raise EditRefusal(f"battery invalid: {error}") from error
|
|
1437
|
+
|
|
1438
|
+
diff = "".join(
|
|
1439
|
+
difflib.unified_diff(
|
|
1440
|
+
original_text.splitlines(keepends=True),
|
|
1441
|
+
new_text.splitlines(keepends=True),
|
|
1442
|
+
fromfile=str(contract_path),
|
|
1443
|
+
tofile=str(contract_path),
|
|
1444
|
+
)
|
|
1445
|
+
)
|
|
1446
|
+
line = f"declared battery capacity (witness {witness}, source {source})"
|
|
1447
|
+
if dry_run:
|
|
1448
|
+
return EditResult(diff=diff, lines=[line], written=False)
|
|
1449
|
+
_replace_then_backup(tmp_path, contract_path, original_text)
|
|
1450
|
+
return EditResult(diff=diff, lines=[line], written=True)
|
|
1451
|
+
finally:
|
|
1452
|
+
tmp_path.unlink(missing_ok=True)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _ensure_list(doc: Any, segments: list[str]) -> list:
|
|
1456
|
+
parent = _navigate(doc, segments[:-1], create=True)
|
|
1457
|
+
key = segments[-1]
|
|
1458
|
+
if key not in parent or parent[key] is None:
|
|
1459
|
+
parent[key] = []
|
|
1460
|
+
node = parent[key]
|
|
1461
|
+
if not isinstance(node, list):
|
|
1462
|
+
raise EditRefusal(f"{'/'.join(segments)} is not a list")
|
|
1463
|
+
return node
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _refuse_duplicate(collection: list, new_id: str, kind: str) -> None:
|
|
1467
|
+
if any(isinstance(x, dict) and x.get("id") == new_id for x in collection):
|
|
1468
|
+
raise EditRefusal(f"{kind} {new_id!r} already exists")
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _stamp_add_provenance(
|
|
1472
|
+
entry: CommentedMap, *, source: str, witness: str, locked_at: dt.date, note: str | None = None
|
|
1473
|
+
) -> None:
|
|
1474
|
+
"""The one shared provenance stamp every ``add``-written record goes
|
|
1475
|
+
through (mutation bite: stamping per record with duplicated inline code
|
|
1476
|
+
instead of this one function is exactly the bug its test catches).
|
|
1477
|
+
|
|
1478
|
+
A one-submit device add calls this 2-3 times with the SAME ``locked_at``
|
|
1479
|
+
object; ruamel anchors/aliases a repeated node by identity, and this repo
|
|
1480
|
+
refuses anchors on read (``_reject_yaml_anchors_aliases``) — so each call
|
|
1481
|
+
gets its own fresh ``date``, never the caller's shared one.
|
|
1482
|
+
"""
|
|
1483
|
+
prov = CommentedMap()
|
|
1484
|
+
prov["witness"] = witness
|
|
1485
|
+
prov["source"] = source
|
|
1486
|
+
prov["locked_at"] = dt.date.fromordinal(locked_at.toordinal())
|
|
1487
|
+
if note:
|
|
1488
|
+
prov["note"] = f"{locked_at.isoformat()}: {note}"
|
|
1489
|
+
entry["provenance"] = prov
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _default_family_name(family_id: str) -> str:
|
|
1493
|
+
"""A readable ``DeviceFamily.name`` when ``add device``'s one-submit path
|
|
1494
|
+
creates a family from only an id and no explicit ``--name``."""
|
|
1495
|
+
tail = family_id.removeprefix("fam.")
|
|
1496
|
+
readable = tail.replace(".", " ").replace("-", " ").replace("_", " ").strip()
|
|
1497
|
+
return readable.title() if readable else family_id
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _build_device(fields: dict[str, Any]) -> CommentedMap:
|
|
1501
|
+
for required in ("id", "model", "owner"):
|
|
1502
|
+
if not fields.get(required):
|
|
1503
|
+
raise EditRefusal(f"device requires --{required.replace('_', '-')}")
|
|
1504
|
+
entry = CommentedMap()
|
|
1505
|
+
entry["id"] = fields["id"]
|
|
1506
|
+
entry["model"] = fields["model"]
|
|
1507
|
+
if fields.get("identity_bus_ref") or fields.get("identity_host"):
|
|
1508
|
+
if not (fields.get("identity_bus_ref") and fields.get("identity_host")):
|
|
1509
|
+
raise EditRefusal("device identity needs both --bus-ref and --host")
|
|
1510
|
+
entry["identity"] = CommentedMap(
|
|
1511
|
+
[("bus_ref", fields["identity_bus_ref"]), ("host", fields["identity_host"])]
|
|
1512
|
+
)
|
|
1513
|
+
if fields.get("physical_mass") or fields.get("physical_mount_frame"):
|
|
1514
|
+
# mass and mount_frame are independently optional — declare either alone.
|
|
1515
|
+
physical = CommentedMap()
|
|
1516
|
+
if fields.get("physical_mass"):
|
|
1517
|
+
physical["mass"] = parse_value(fields["physical_mass"], field_name="mass")
|
|
1518
|
+
if fields.get("physical_mount_frame"):
|
|
1519
|
+
physical["mount_frame"] = fields["physical_mount_frame"]
|
|
1520
|
+
entry["physical"] = physical
|
|
1521
|
+
if fields.get("power_rail") or fields.get("power_draw"):
|
|
1522
|
+
if fields.get("power_draw") and not fields.get("power_rail"):
|
|
1523
|
+
raise EditRefusal("device power draw needs --rail")
|
|
1524
|
+
power = CommentedMap()
|
|
1525
|
+
power["rail"] = fields["power_rail"]
|
|
1526
|
+
# draw is optional — a device may declare a rail with unknown draw.
|
|
1527
|
+
if fields.get("power_draw"):
|
|
1528
|
+
power["draw"] = parse_value(fields["power_draw"], field_name="draw")
|
|
1529
|
+
entry["power"] = power
|
|
1530
|
+
entry["owner"] = fields["owner"]
|
|
1531
|
+
return entry
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _build_rail(fields: dict[str, Any]) -> CommentedMap:
|
|
1535
|
+
for required in ("id", "capacity"):
|
|
1536
|
+
if not fields.get(required):
|
|
1537
|
+
raise EditRefusal(f"rail requires --{required}")
|
|
1538
|
+
entry = CommentedMap()
|
|
1539
|
+
entry["id"] = fields["id"]
|
|
1540
|
+
# voltage is optional — capacity alone still describes a rail.
|
|
1541
|
+
if fields.get("voltage"):
|
|
1542
|
+
entry["voltage"] = parse_value(fields["voltage"], field_name="voltage")
|
|
1543
|
+
entry["capacity"] = parse_value(fields["capacity"], field_name="capacity")
|
|
1544
|
+
if fields.get("upstream"):
|
|
1545
|
+
entry["upstream"] = fields["upstream"]
|
|
1546
|
+
if fields.get("efficiency"):
|
|
1547
|
+
entry["efficiency"] = parse_value(fields["efficiency"], field_name="efficiency")
|
|
1548
|
+
return entry
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _build_bus(fields: dict[str, Any]) -> CommentedMap:
|
|
1552
|
+
for required in ("id", "kind", "host"):
|
|
1553
|
+
if not fields.get(required):
|
|
1554
|
+
raise EditRefusal(f"bus requires --{required}")
|
|
1555
|
+
entry = CommentedMap()
|
|
1556
|
+
entry["id"] = fields["id"]
|
|
1557
|
+
entry["kind"] = fields["kind"]
|
|
1558
|
+
entry["host"] = fields["host"]
|
|
1559
|
+
if fields.get("bitrate"):
|
|
1560
|
+
entry["bitrate"] = parse_value(fields["bitrate"], field_name="bitrate")
|
|
1561
|
+
return entry
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _build_family(fields: dict[str, Any]) -> CommentedMap:
|
|
1565
|
+
for required in ("id", "name"):
|
|
1566
|
+
if not fields.get(required):
|
|
1567
|
+
raise EditRefusal(f"family requires --{required}")
|
|
1568
|
+
entry = CommentedMap()
|
|
1569
|
+
entry["id"] = fields["id"]
|
|
1570
|
+
entry["name"] = fields["name"]
|
|
1571
|
+
if fields.get("min_capture_window_s"):
|
|
1572
|
+
entry["min_capture_window_s"] = parse_value(
|
|
1573
|
+
fields["min_capture_window_s"], field_name="min_capture_window_s"
|
|
1574
|
+
)
|
|
1575
|
+
if fields.get("notes"):
|
|
1576
|
+
entry["notes"] = fields["notes"]
|
|
1577
|
+
return entry
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def _build_model(fields: dict[str, Any], doc: Any) -> CommentedMap:
|
|
1581
|
+
for required in ("id", "family"):
|
|
1582
|
+
if not fields.get(required):
|
|
1583
|
+
raise EditRefusal(f"model requires --{required}")
|
|
1584
|
+
family_ref = fields["family"]
|
|
1585
|
+
families = doc.get("families", []) or []
|
|
1586
|
+
if not any(isinstance(f, dict) and f.get("id") == family_ref for f in families):
|
|
1587
|
+
existing = ", ".join(
|
|
1588
|
+
sorted(f["id"] for f in families if isinstance(f, dict) and f.get("id"))
|
|
1589
|
+
)
|
|
1590
|
+
raise EditRefusal(
|
|
1591
|
+
f"family {family_ref!r} is not declared — declare it first: "
|
|
1592
|
+
"lockwire add family ... --id <fam.id> --name ... --source ... --witness ... "
|
|
1593
|
+
f"(or use an existing id: {existing or 'none declared yet'})"
|
|
1594
|
+
)
|
|
1595
|
+
entry = CommentedMap()
|
|
1596
|
+
entry["id"] = fields["id"]
|
|
1597
|
+
entry["family"] = family_ref
|
|
1598
|
+
input_min = fields.get("input_voltage_min")
|
|
1599
|
+
input_max = fields.get("input_voltage_max")
|
|
1600
|
+
if input_min or input_max:
|
|
1601
|
+
if not (input_min and input_max):
|
|
1602
|
+
raise EditRefusal(
|
|
1603
|
+
"model input voltage range needs both --input-voltage-min and --input-voltage-max"
|
|
1604
|
+
)
|
|
1605
|
+
entry["input_voltage_range"] = CommentedMap(
|
|
1606
|
+
[
|
|
1607
|
+
("min", parse_value(input_min, field_name="min")),
|
|
1608
|
+
("max", parse_value(input_max, field_name="max")),
|
|
1609
|
+
]
|
|
1610
|
+
)
|
|
1611
|
+
return entry
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _build_budget(fields: dict[str, Any], doc: Any) -> CommentedMap:
|
|
1615
|
+
for required in ("id", "budget_kind", "limit", "owner"):
|
|
1616
|
+
if not fields.get(required):
|
|
1617
|
+
raise EditRefusal(f"budget requires --{required.replace('budget_', '')}")
|
|
1618
|
+
kind = fields["budget_kind"]
|
|
1619
|
+
if kind not in ("rail_power", "endurance", "mass_total"):
|
|
1620
|
+
raise EditRefusal(
|
|
1621
|
+
f"budget --kind must be one of rail_power|endurance|mass_total, got {kind!r}"
|
|
1622
|
+
)
|
|
1623
|
+
rail_ref = fields.get("rail")
|
|
1624
|
+
if kind == "rail_power":
|
|
1625
|
+
if not rail_ref:
|
|
1626
|
+
raise EditRefusal("budget --kind rail_power requires --rail")
|
|
1627
|
+
rails = (
|
|
1628
|
+
doc.get("topology", {}).get("rails", [])
|
|
1629
|
+
if isinstance(doc.get("topology"), dict)
|
|
1630
|
+
else []
|
|
1631
|
+
)
|
|
1632
|
+
if not any(isinstance(r, dict) and r.get("id") == rail_ref for r in rails):
|
|
1633
|
+
raise EditRefusal(f"budget references unknown rail {rail_ref!r}")
|
|
1634
|
+
elif rail_ref:
|
|
1635
|
+
raise EditRefusal(f"budget --kind {kind} takes no --rail")
|
|
1636
|
+
|
|
1637
|
+
entry = CommentedMap()
|
|
1638
|
+
entry["id"] = fields["id"]
|
|
1639
|
+
entry["kind"] = kind
|
|
1640
|
+
entry["limit"] = parse_value(fields["limit"], field_name="limit")
|
|
1641
|
+
if rail_ref:
|
|
1642
|
+
entry["rail"] = rail_ref
|
|
1643
|
+
if fields.get("draw_mode"):
|
|
1644
|
+
entry["draw_mode"] = fields["draw_mode"]
|
|
1645
|
+
if fields.get("sources"):
|
|
1646
|
+
entry["sources"] = fields["sources"]
|
|
1647
|
+
entry["owner"] = fields["owner"]
|
|
1648
|
+
return entry
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def _build_gearbox(fields: dict[str, Any], doc: Any) -> CommentedMap:
|
|
1652
|
+
for required in ("id", "ratio", "input_joint", "output_joint"):
|
|
1653
|
+
if fields.get(required) in (None, ""):
|
|
1654
|
+
raise EditRefusal(f"gearbox requires --{required.replace('_', '-')}")
|
|
1655
|
+
joints = doc.get("model", {}).get("joints", []) if isinstance(doc.get("model"), dict) else []
|
|
1656
|
+
joint_names = {j.get("name") for j in joints if isinstance(j, dict)}
|
|
1657
|
+
for role in ("input_joint", "output_joint"):
|
|
1658
|
+
ref = fields[role]
|
|
1659
|
+
if ref not in joint_names:
|
|
1660
|
+
raise EditRefusal(f"gearbox {role} references unknown joint {ref!r}")
|
|
1661
|
+
try:
|
|
1662
|
+
ratio = float(fields["ratio"])
|
|
1663
|
+
except (TypeError, ValueError) as error:
|
|
1664
|
+
raise EditRefusal(f"gearbox --ratio must be a number, got {fields['ratio']!r}") from error
|
|
1665
|
+
if ratio <= 0:
|
|
1666
|
+
raise EditRefusal("gearbox --ratio must be > 0")
|
|
1667
|
+
entry = CommentedMap()
|
|
1668
|
+
entry["id"] = fields["id"]
|
|
1669
|
+
entry["ratio"] = ratio
|
|
1670
|
+
entry["input_joint"] = fields["input_joint"]
|
|
1671
|
+
entry["output_joint"] = fields["output_joint"]
|
|
1672
|
+
return entry
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
# ------------------------------------------------------- pose edits (move/rotate/reparent)
|
|
1676
|
+
#
|
|
1677
|
+
# The "editable minor CAD changes". Each one is a FILE operation on
|
|
1678
|
+
# the contract, never a mutation of a live scene object: `scene.ScenePart` is a
|
|
1679
|
+
# frozen dataclass of baked world-space vertices with no pose, no parent and no
|
|
1680
|
+
# mesh reference (measured, `scene.py:38-48`), so the scene is a render product
|
|
1681
|
+
# and the contract is the thing that can be edited. The scene is rebuilt from the
|
|
1682
|
+
# contract afterwards by the code that already builds it.
|
|
1683
|
+
#
|
|
1684
|
+
# All three go through `apply_changes` — the same writer `lockwire declare` calls
|
|
1685
|
+
# — so source, witness and provenance are stamped by one gate, and a
|
|
1686
|
+
# refusal raised here happens before `apply_changes` opens the file at all, which
|
|
1687
|
+
# is what leaves a refused edit byte-identical.
|
|
1688
|
+
|
|
1689
|
+
#: Accepted rotation units and their factor to radians. `JointOrigin.rpy` is a
|
|
1690
|
+
#: bare radian triple (URDF-native), so radians is what gets written.
|
|
1691
|
+
#: `units.py` is deliberately NOT the table here: its `angle` dimension is based
|
|
1692
|
+
#: on degrees and carries no `rad` entry, and adding one would change every angle
|
|
1693
|
+
#: comparison in the codebase for the sake of a field that stores no unit at all.
|
|
1694
|
+
_ROTATION_UNITS = {"rad": 1.0, "deg": math.pi / 180.0}
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def _require_unit(unit: str | None, accepted: tuple[str, ...], *, what: str) -> str:
|
|
1698
|
+
"""Refuse a missing or wrong-dimension unit rather than guessing one.
|
|
1699
|
+
|
|
1700
|
+
The joint-limit rule read across to pose: a number without its unit is
|
|
1701
|
+
refused, never defaulted. Defaulting is the failure this exists to prevent —
|
|
1702
|
+
``--rpy 0,0,90`` meant in degrees and silently read as radians is a 90-radian
|
|
1703
|
+
rotation that no later check can tell from an intended one.
|
|
1704
|
+
"""
|
|
1705
|
+
if unit is None or not unit.strip():
|
|
1706
|
+
raise EditRefusal(
|
|
1707
|
+
f"--unit is required for {what}: give one of {', '.join(accepted)}. "
|
|
1708
|
+
"A bare number has no meaning without it and lockwire does not guess."
|
|
1709
|
+
)
|
|
1710
|
+
if unit.strip() not in accepted:
|
|
1711
|
+
raise EditRefusal(
|
|
1712
|
+
f"--unit {unit.strip()!r} is not a {what} unit; give one of {', '.join(accepted)}"
|
|
1713
|
+
)
|
|
1714
|
+
return unit.strip()
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _length_units() -> tuple[str, ...]:
|
|
1718
|
+
"""Every length unit `units.py` knows, so the two never drift apart."""
|
|
1719
|
+
return tuple(u for u in ("m", "cm", "mm") if units.known(u))
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def _link_tree(contract: Any) -> tuple[dict[str, str], dict[str, str], set[str]]:
|
|
1723
|
+
"""ONE walk of the recorded model: child->parent, child->joint name, link names.
|
|
1724
|
+
|
|
1725
|
+
Every pose refusal below reads this one map. A second tree walk that
|
|
1726
|
+
disagreed with this one is exactly how a cycle would get into the file,
|
|
1727
|
+
and a cycle in the file makes `checks._fixed_frame_transforms` return no
|
|
1728
|
+
transform for the whole subtree — the CG composition would go quiet rather
|
|
1729
|
+
than wrong, and nothing else in the repo would notice.
|
|
1730
|
+
|
|
1731
|
+
ponytail: a link named as the child of two joints keeps the last one seen.
|
|
1732
|
+
The schema forbids duplicate joint *names*, not duplicate children, so that
|
|
1733
|
+
model is already outside the tree these operations claim to edit; the
|
|
1734
|
+
root/orphan check below refuses it in practice by failing to reach the root.
|
|
1735
|
+
"""
|
|
1736
|
+
if contract.model is None:
|
|
1737
|
+
raise EditRefusal(
|
|
1738
|
+
"this contract records no model, so there is no link tree to edit — "
|
|
1739
|
+
"run `lockwire start <urdf>` or `lockwire init` against a model first"
|
|
1740
|
+
)
|
|
1741
|
+
parent_of: dict[str, str] = {}
|
|
1742
|
+
joint_of: dict[str, str] = {}
|
|
1743
|
+
for joint in contract.model.joints:
|
|
1744
|
+
parent_of[joint.child] = joint.parent
|
|
1745
|
+
joint_of[joint.child] = joint.name
|
|
1746
|
+
return parent_of, joint_of, {link.name for link in contract.model.links}
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def _chain_to_root(parent_of: dict[str, str], link: str) -> list[str]:
|
|
1750
|
+
"""``link`` then every ancestor above it, nearest first, ending at its root.
|
|
1751
|
+
|
|
1752
|
+
Stops if it meets a link twice, so a cycle already present in the file makes
|
|
1753
|
+
this terminate rather than hang.
|
|
1754
|
+
"""
|
|
1755
|
+
chain, seen = [link], {link}
|
|
1756
|
+
while link in parent_of:
|
|
1757
|
+
link = parent_of[link]
|
|
1758
|
+
if link in seen:
|
|
1759
|
+
break
|
|
1760
|
+
chain.append(link)
|
|
1761
|
+
seen.add(link)
|
|
1762
|
+
return chain
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def _resolve_pose_target(contract: Any, target: str) -> tuple[str, str]:
|
|
1766
|
+
"""``("device", id)`` or ``("link", name)`` — what ``target`` names, or a refusal."""
|
|
1767
|
+
if any(device.id == target for device in contract.devices):
|
|
1768
|
+
return "device", target
|
|
1769
|
+
if contract.model is not None and any(link.name == target for link in contract.model.links):
|
|
1770
|
+
return "link", target
|
|
1771
|
+
raise EditRefusal(
|
|
1772
|
+
f"{target!r} is neither a declared device id nor a link in the recorded model; "
|
|
1773
|
+
"a pose edit addresses one of those two"
|
|
1774
|
+
)
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def _joint_placing(contract: Any, link: str, *, verb: str) -> str:
|
|
1778
|
+
"""The joint whose child is ``link`` — the joint that places it in its parent."""
|
|
1779
|
+
_parent_of, joint_of, _links = _link_tree(contract)
|
|
1780
|
+
if link not in joint_of:
|
|
1781
|
+
raise EditRefusal(
|
|
1782
|
+
f"link {link!r} is the model root: no joint makes it a child, so there is "
|
|
1783
|
+
f"no recorded pose to {verb}. Its frame is the model's own origin."
|
|
1784
|
+
)
|
|
1785
|
+
return joint_of[link]
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _pose_change(
|
|
1789
|
+
path: str,
|
|
1790
|
+
value: Any,
|
|
1791
|
+
*,
|
|
1792
|
+
source: str,
|
|
1793
|
+
witness: str,
|
|
1794
|
+
note: str | None,
|
|
1795
|
+
locked_at: dt.date | None,
|
|
1796
|
+
unit: str | None = None,
|
|
1797
|
+
) -> Change:
|
|
1798
|
+
return Change(
|
|
1799
|
+
path=path,
|
|
1800
|
+
value=value,
|
|
1801
|
+
source=source,
|
|
1802
|
+
witness=witness,
|
|
1803
|
+
note=note,
|
|
1804
|
+
locked_at=locked_at,
|
|
1805
|
+
unit=unit,
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
@_locked_write
|
|
1810
|
+
def place(
|
|
1811
|
+
contract_path: Path,
|
|
1812
|
+
target: str,
|
|
1813
|
+
on: str,
|
|
1814
|
+
offset: tuple[float, float, float],
|
|
1815
|
+
*,
|
|
1816
|
+
unit: str | None,
|
|
1817
|
+
source: str,
|
|
1818
|
+
witness: str,
|
|
1819
|
+
note: str | None = None,
|
|
1820
|
+
locked_at: dt.date | None = None,
|
|
1821
|
+
dry_run: bool = False,
|
|
1822
|
+
expected_revision: str | None = None,
|
|
1823
|
+
) -> EditResult:
|
|
1824
|
+
"""Place an existing device on a model link in one atomic contract write."""
|
|
1825
|
+
contract = load_contract(contract_path)
|
|
1826
|
+
kind, name = _resolve_pose_target(contract, target)
|
|
1827
|
+
if kind != "device":
|
|
1828
|
+
raise EditRefusal(
|
|
1829
|
+
f"{name!r} is a model link and already has a placing joint; "
|
|
1830
|
+
"use move, rotate or reparent to edit it"
|
|
1831
|
+
)
|
|
1832
|
+
_parent_of, _joint_of, links = _link_tree(contract)
|
|
1833
|
+
if on not in links:
|
|
1834
|
+
raise EditRefusal(
|
|
1835
|
+
f"placement parent {on!r} does not resolve: the recorded model has no link by that name"
|
|
1836
|
+
)
|
|
1837
|
+
unit = _require_unit(unit, _length_units(), what="an offset")
|
|
1838
|
+
return apply_changes(
|
|
1839
|
+
contract_path,
|
|
1840
|
+
[
|
|
1841
|
+
_pose_change(
|
|
1842
|
+
f"devices/{name}/physical/mount_frame",
|
|
1843
|
+
f'"{on}"',
|
|
1844
|
+
source=source,
|
|
1845
|
+
witness=witness,
|
|
1846
|
+
note=note,
|
|
1847
|
+
locked_at=locked_at,
|
|
1848
|
+
),
|
|
1849
|
+
_pose_change(
|
|
1850
|
+
f"devices/{name}/physical/mount_xyz",
|
|
1851
|
+
[float(v) for v in offset],
|
|
1852
|
+
unit=unit,
|
|
1853
|
+
source=source,
|
|
1854
|
+
witness=witness,
|
|
1855
|
+
note=note,
|
|
1856
|
+
locked_at=locked_at,
|
|
1857
|
+
),
|
|
1858
|
+
],
|
|
1859
|
+
dry_run=dry_run,
|
|
1860
|
+
expected_revision=expected_revision,
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
@_locked_write
|
|
1865
|
+
def set_mount_shape(
|
|
1866
|
+
contract_path: Path,
|
|
1867
|
+
target: str,
|
|
1868
|
+
shape: dict[str, Any],
|
|
1869
|
+
*,
|
|
1870
|
+
source: str,
|
|
1871
|
+
witness: str,
|
|
1872
|
+
note: str | None = None,
|
|
1873
|
+
locked_at: dt.date | None = None,
|
|
1874
|
+
dry_run: bool = False,
|
|
1875
|
+
expected_revision: str | None = None,
|
|
1876
|
+
) -> EditResult:
|
|
1877
|
+
"""Write one device mount shape through the same atomic gate as declare."""
|
|
1878
|
+
contract = load_contract(contract_path)
|
|
1879
|
+
kind, name = _resolve_pose_target(contract, target)
|
|
1880
|
+
if kind != "device":
|
|
1881
|
+
raise EditRefusal(f"{name!r} is a model link; mount_shape belongs to device physical")
|
|
1882
|
+
return apply_changes(
|
|
1883
|
+
contract_path,
|
|
1884
|
+
[
|
|
1885
|
+
_pose_change(
|
|
1886
|
+
f"devices/{name}/physical/mount_shape",
|
|
1887
|
+
shape,
|
|
1888
|
+
source=source,
|
|
1889
|
+
witness=witness,
|
|
1890
|
+
note=note,
|
|
1891
|
+
locked_at=locked_at,
|
|
1892
|
+
)
|
|
1893
|
+
],
|
|
1894
|
+
dry_run=dry_run,
|
|
1895
|
+
expected_revision=expected_revision,
|
|
1896
|
+
)
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
@_locked_write
|
|
1900
|
+
def move(
|
|
1901
|
+
contract_path: Path,
|
|
1902
|
+
target: str,
|
|
1903
|
+
offset: tuple[float, float, float],
|
|
1904
|
+
*,
|
|
1905
|
+
unit: str | None,
|
|
1906
|
+
source: str,
|
|
1907
|
+
witness: str,
|
|
1908
|
+
note: str | None = None,
|
|
1909
|
+
locked_at: dt.date | None = None,
|
|
1910
|
+
dry_run: bool = False,
|
|
1911
|
+
expected_revision: str | None = None,
|
|
1912
|
+
) -> EditResult:
|
|
1913
|
+
"""Set where ``target`` sits in the frame that already holds it.
|
|
1914
|
+
|
|
1915
|
+
A device moves in its ``mount_frame`` (``devices/<id>/physical/mount_xyz``);
|
|
1916
|
+
a link moves in its parent link's frame, which is its placing joint's origin
|
|
1917
|
+
(``model/joints/<joint>/origin/xyz``, meters, URDF-native).
|
|
1918
|
+
"""
|
|
1919
|
+
contract = load_contract(contract_path)
|
|
1920
|
+
kind, name = _resolve_pose_target(contract, target)
|
|
1921
|
+
unit = _require_unit(unit, _length_units(), what="an offset")
|
|
1922
|
+
if kind == "device":
|
|
1923
|
+
change = _pose_change(
|
|
1924
|
+
f"devices/{name}/physical/mount_xyz",
|
|
1925
|
+
[float(v) for v in offset],
|
|
1926
|
+
source=source,
|
|
1927
|
+
witness=witness,
|
|
1928
|
+
note=note,
|
|
1929
|
+
locked_at=locked_at,
|
|
1930
|
+
unit=unit,
|
|
1931
|
+
)
|
|
1932
|
+
else:
|
|
1933
|
+
joint = _joint_placing(contract, name, verb="move")
|
|
1934
|
+
change = _pose_change(
|
|
1935
|
+
f"model/joints/{joint}/origin/xyz",
|
|
1936
|
+
[units.convert(float(v), unit, "m") for v in offset],
|
|
1937
|
+
source=source,
|
|
1938
|
+
witness=witness,
|
|
1939
|
+
note=note,
|
|
1940
|
+
locked_at=locked_at,
|
|
1941
|
+
)
|
|
1942
|
+
return apply_changes(
|
|
1943
|
+
contract_path, [change], dry_run=dry_run, expected_revision=expected_revision
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
@_locked_write
|
|
1948
|
+
def rotate(
|
|
1949
|
+
contract_path: Path,
|
|
1950
|
+
target: str,
|
|
1951
|
+
rpy: tuple[float, float, float],
|
|
1952
|
+
*,
|
|
1953
|
+
unit: str | None,
|
|
1954
|
+
source: str,
|
|
1955
|
+
witness: str,
|
|
1956
|
+
note: str | None = None,
|
|
1957
|
+
locked_at: dt.date | None = None,
|
|
1958
|
+
dry_run: bool = False,
|
|
1959
|
+
expected_revision: str | None = None,
|
|
1960
|
+
) -> EditResult:
|
|
1961
|
+
"""Set ``target``'s orientation in the frame that already holds it.
|
|
1962
|
+
|
|
1963
|
+
Worth stating plainly: **a device has no recorded orientation.**
|
|
1964
|
+
``schema.Physical`` carries mass, placement and mount shape, but no orientation. The only rotation
|
|
1965
|
+
the contract records is a joint's ``origin.rpy``, so ``rotate`` addresses a
|
|
1966
|
+
link, and a device target is refused in words that say why rather than
|
|
1967
|
+
inventing a field.
|
|
1968
|
+
"""
|
|
1969
|
+
contract = load_contract(contract_path)
|
|
1970
|
+
kind, name = _resolve_pose_target(contract, target)
|
|
1971
|
+
unit = _require_unit(unit, tuple(_ROTATION_UNITS), what="a rotation")
|
|
1972
|
+
if kind == "device":
|
|
1973
|
+
raise EditRefusal(
|
|
1974
|
+
f"{name!r} is a device, and the contract records no orientation for a device: "
|
|
1975
|
+
"`physical` carries mass, placement and mount shape, but no orientation. "
|
|
1976
|
+
"Rotate the link it is mounted to instead, and if a device really "
|
|
1977
|
+
"needs its own orientation that is a schema change (open an issue to propose it)."
|
|
1978
|
+
)
|
|
1979
|
+
joint = _joint_placing(contract, name, verb="rotate")
|
|
1980
|
+
factor = _ROTATION_UNITS[unit]
|
|
1981
|
+
change = _pose_change(
|
|
1982
|
+
f"model/joints/{joint}/origin/rpy",
|
|
1983
|
+
[float(v) * factor for v in rpy],
|
|
1984
|
+
source=source,
|
|
1985
|
+
witness=witness,
|
|
1986
|
+
note=note,
|
|
1987
|
+
locked_at=locked_at,
|
|
1988
|
+
)
|
|
1989
|
+
return apply_changes(
|
|
1990
|
+
contract_path, [change], dry_run=dry_run, expected_revision=expected_revision
|
|
1991
|
+
)
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
@_locked_write
|
|
1995
|
+
def reparent(
|
|
1996
|
+
contract_path: Path,
|
|
1997
|
+
target: str,
|
|
1998
|
+
to: str,
|
|
1999
|
+
*,
|
|
2000
|
+
source: str,
|
|
2001
|
+
witness: str,
|
|
2002
|
+
note: str | None = None,
|
|
2003
|
+
locked_at: dt.date | None = None,
|
|
2004
|
+
dry_run: bool = False,
|
|
2005
|
+
expected_revision: str | None = None,
|
|
2006
|
+
) -> EditResult:
|
|
2007
|
+
"""Hang ``target`` off ``to`` instead of whatever holds it now.
|
|
2008
|
+
|
|
2009
|
+
A device changes the link it is mounted to (``physical/mount_frame``); a link
|
|
2010
|
+
changes its placing joint's ``parent``, which moves the link tree and
|
|
2011
|
+
therefore ``structure_hash`` — that move is PRINTED, never silent, because a
|
|
2012
|
+
silent one makes every later model import look like drift
|
|
2013
|
+
(``urdf.describe_structure_drift``).
|
|
2014
|
+
|
|
2015
|
+
Three refusals, each naming which one it is rather than failing generically:
|
|
2016
|
+
a parent that does not resolve, a re-parent that closes a CYCLE (``to`` is
|
|
2017
|
+
``target`` or sits below it), and one that would ORPHAN ``target``'s subtree
|
|
2018
|
+
(``to`` is not connected to the root ``target`` currently hangs from).
|
|
2019
|
+
"""
|
|
2020
|
+
contract = load_contract(contract_path)
|
|
2021
|
+
kind, name = _resolve_pose_target(contract, target)
|
|
2022
|
+
parent_of, joint_of, links = _link_tree(contract)
|
|
2023
|
+
if to not in links:
|
|
2024
|
+
raise EditRefusal(
|
|
2025
|
+
f"parent {to!r} does not resolve: the recorded model has no link by that name "
|
|
2026
|
+
f"(it has {', '.join(sorted(links)) or 'no links'})"
|
|
2027
|
+
)
|
|
2028
|
+
if kind == "device":
|
|
2029
|
+
change = _pose_change(
|
|
2030
|
+
f"devices/{name}/physical/mount_frame",
|
|
2031
|
+
f'"{to}"',
|
|
2032
|
+
source=source,
|
|
2033
|
+
witness=witness,
|
|
2034
|
+
note=note,
|
|
2035
|
+
locked_at=locked_at,
|
|
2036
|
+
)
|
|
2037
|
+
return apply_changes(
|
|
2038
|
+
contract_path, [change], dry_run=dry_run, expected_revision=expected_revision
|
|
2039
|
+
)
|
|
2040
|
+
|
|
2041
|
+
if to == name:
|
|
2042
|
+
raise EditRefusal(
|
|
2043
|
+
f"re-parenting link {name!r} onto itself closes a CYCLE in the link tree: "
|
|
2044
|
+
"a link cannot be its own parent"
|
|
2045
|
+
)
|
|
2046
|
+
chain = _chain_to_root(parent_of, to)
|
|
2047
|
+
if name in chain:
|
|
2048
|
+
below = " -> ".join(reversed(chain[: chain.index(name) + 1]))
|
|
2049
|
+
raise EditRefusal(
|
|
2050
|
+
f"re-parenting link {name!r} onto {to!r} closes a CYCLE in the link tree: "
|
|
2051
|
+
f"{to!r} already sits below {name!r} ({below}). A cycle in the link tree makes "
|
|
2052
|
+
"every later tree walk in the contract either stop early or report no transform "
|
|
2053
|
+
"at all, so it is refused rather than written"
|
|
2054
|
+
)
|
|
2055
|
+
anchor = _chain_to_root(parent_of, name)[-1]
|
|
2056
|
+
if chain[-1] != anchor:
|
|
2057
|
+
raise EditRefusal(
|
|
2058
|
+
f"re-parenting link {name!r} onto {to!r} would ORPHAN {name!r} and everything "
|
|
2059
|
+
f"below it: {to!r} is not connected to the model root {anchor!r} (it reaches "
|
|
2060
|
+
f"{chain[-1]!r} instead), so the moved subtree would hang off nothing"
|
|
2061
|
+
)
|
|
2062
|
+
|
|
2063
|
+
joint = _joint_placing(contract, name, verb="re-parent")
|
|
2064
|
+
model = contract.model
|
|
2065
|
+
assert model is not None # _link_tree already refused a contract with no model
|
|
2066
|
+
old_hash = model.structure_hash
|
|
2067
|
+
new_hash = _model_structure_hash(
|
|
2068
|
+
model.links,
|
|
2069
|
+
[j.model_copy(update={"parent": to}) if j.name == joint else j for j in model.joints],
|
|
2070
|
+
)
|
|
2071
|
+
result = apply_changes(
|
|
2072
|
+
contract_path,
|
|
2073
|
+
[
|
|
2074
|
+
_pose_change(
|
|
2075
|
+
f"model/joints/{joint}/parent",
|
|
2076
|
+
f'"{to}"',
|
|
2077
|
+
source=source,
|
|
2078
|
+
witness=witness,
|
|
2079
|
+
note=note,
|
|
2080
|
+
locked_at=locked_at,
|
|
2081
|
+
),
|
|
2082
|
+
_pose_change(
|
|
2083
|
+
"model/structure_hash",
|
|
2084
|
+
f'"{new_hash}"',
|
|
2085
|
+
source=source,
|
|
2086
|
+
witness=witness,
|
|
2087
|
+
note=note,
|
|
2088
|
+
locked_at=locked_at,
|
|
2089
|
+
),
|
|
2090
|
+
],
|
|
2091
|
+
dry_run=dry_run,
|
|
2092
|
+
expected_revision=expected_revision,
|
|
2093
|
+
)
|
|
2094
|
+
result.lines.append(
|
|
2095
|
+
f"structure hash moved: {old_hash} -> {new_hash} "
|
|
2096
|
+
f"(the link tree changed, so every later model import compares against the new one)"
|
|
2097
|
+
)
|
|
2098
|
+
return result
|