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/scene_import.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""``lockwire scene import``: undeclared device placements read from a scene into an
|
|
2
|
+
apply-ready change file (bulk import is allowed).
|
|
3
|
+
|
|
4
|
+
The YAML is the truth, a scene never changes a contract value on its
|
|
5
|
+
own. Imports propose one sourced and witnessed entry per device. The
|
|
6
|
+
explicit --write shortcut applies those entries through the guarded contract editor.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ruamel.yaml import YAML
|
|
20
|
+
from ruamel.yaml.comments import CommentedSeq
|
|
21
|
+
|
|
22
|
+
from lockwire.checks import PLACEMENT_TOLERANCE_M, _fixed_frame_transforms
|
|
23
|
+
from lockwire.edit import (
|
|
24
|
+
Change,
|
|
25
|
+
EditConflict,
|
|
26
|
+
_backup_temp,
|
|
27
|
+
_locked_write,
|
|
28
|
+
_replace_then_backup,
|
|
29
|
+
apply_changes,
|
|
30
|
+
propose_changes,
|
|
31
|
+
validate_source,
|
|
32
|
+
validate_witness,
|
|
33
|
+
)
|
|
34
|
+
from lockwire.scene import Scene
|
|
35
|
+
from lockwire.scene_view import resolve_scene
|
|
36
|
+
from lockwire.schema import Contract
|
|
37
|
+
|
|
38
|
+
_ROUND = 4
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SceneImportRefusal(ValueError):
|
|
42
|
+
"""The import cannot proceed; message names the defect. CLI maps this to exit 2."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ImportRow:
|
|
47
|
+
device_id: str
|
|
48
|
+
model_position: tuple[float, float, float] | None
|
|
49
|
+
declared: tuple[float, float, float] | None
|
|
50
|
+
action: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ImportResult:
|
|
55
|
+
rows: tuple[ImportRow, ...]
|
|
56
|
+
entries: tuple[dict[str, Any], ...]
|
|
57
|
+
skipped_non_devices: tuple[str, ...]
|
|
58
|
+
root: str
|
|
59
|
+
explanation: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_point(point: tuple[float, ...]) -> str:
|
|
63
|
+
return "(" + ", ".join(f"{v:.4f}" for v in point) + ")"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_import(
|
|
67
|
+
contract: Contract,
|
|
68
|
+
scene: Scene,
|
|
69
|
+
*,
|
|
70
|
+
witness: str,
|
|
71
|
+
source_person: str,
|
|
72
|
+
only: list[str] | None = None,
|
|
73
|
+
declared_too: bool = False,
|
|
74
|
+
link_based: bool = False,
|
|
75
|
+
) -> ImportResult:
|
|
76
|
+
root, _transforms, _limitations = _fixed_frame_transforms(contract)
|
|
77
|
+
if root is None:
|
|
78
|
+
raise SceneImportRefusal(
|
|
79
|
+
"the contract's model has no single root link; mount_frame cannot be "
|
|
80
|
+
"assigned automatically."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
resolved = resolve_scene(contract, scene)
|
|
84
|
+
parts_by_id = {}
|
|
85
|
+
for part, part_id in zip(scene.parts, resolved.part_ids, strict=True):
|
|
86
|
+
if part_id is not None:
|
|
87
|
+
parts_by_id.setdefault(part_id, part)
|
|
88
|
+
|
|
89
|
+
device_ids = {device.id for device in contract.devices}
|
|
90
|
+
skipped_non_devices = tuple(sorted({pid for pid in parts_by_id if pid not in device_ids}))
|
|
91
|
+
|
|
92
|
+
devices = contract.devices
|
|
93
|
+
if only is not None:
|
|
94
|
+
wanted = set(only)
|
|
95
|
+
devices = [d for d in devices if d.id in wanted]
|
|
96
|
+
|
|
97
|
+
if not any(d.id in parts_by_id for d in devices):
|
|
98
|
+
if link_based:
|
|
99
|
+
# A URDF/SDF's parts are links, not devices (the name join). Nothing joining
|
|
100
|
+
# is a normal outcome of a fresh model, not a tool failure — explain it in
|
|
101
|
+
# plain words and hand back zero entries rather than refusing.
|
|
102
|
+
link_ids = ", ".join(sorted(parts_by_id)) or "no links at all"
|
|
103
|
+
device_ids_str = ", ".join(sorted(d.id for d in devices)) or "no devices"
|
|
104
|
+
explanation = (
|
|
105
|
+
f"the model names {link_ids}; the contract's devices are {device_ids_str}; "
|
|
106
|
+
"no device is called that — name a device after its link, or give the "
|
|
107
|
+
"model extras.lockwire.id"
|
|
108
|
+
)
|
|
109
|
+
return ImportResult(
|
|
110
|
+
rows=tuple(
|
|
111
|
+
ImportRow(device.id, None, None, "not in the model") for device in devices
|
|
112
|
+
),
|
|
113
|
+
entries=(),
|
|
114
|
+
skipped_non_devices=skipped_non_devices,
|
|
115
|
+
root=root,
|
|
116
|
+
explanation=explanation,
|
|
117
|
+
)
|
|
118
|
+
raise SceneImportRefusal(
|
|
119
|
+
"no device parts found in the scene for the requested devices; nothing to import."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
today = dt.date.today().isoformat()
|
|
123
|
+
rows: list[ImportRow] = []
|
|
124
|
+
entries: list[dict[str, Any]] = []
|
|
125
|
+
|
|
126
|
+
for device in devices:
|
|
127
|
+
part = parts_by_id.get(device.id)
|
|
128
|
+
if part is None:
|
|
129
|
+
rows.append(ImportRow(device.id, None, None, "not in the model"))
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
model_pos = tuple(round(v, _ROUND) for v in part.translation)
|
|
133
|
+
physical = device.physical
|
|
134
|
+
source = f"{scene.source} node {device.id}; confirmed by {source_person}"
|
|
135
|
+
note = f"position read from the model on {today} and confirmed by {source_person}"
|
|
136
|
+
|
|
137
|
+
if physical is not None and physical.mount_xyz is not None:
|
|
138
|
+
declared = tuple(round(c.base(), _ROUND) for c in physical.mount_xyz)
|
|
139
|
+
distance_mm = (
|
|
140
|
+
math.sqrt(sum((a - b) ** 2 for a, b in zip(model_pos, declared, strict=True)))
|
|
141
|
+
* 1000
|
|
142
|
+
)
|
|
143
|
+
if distance_mm / 1000 <= PLACEMENT_TOLERANCE_M:
|
|
144
|
+
rows.append(ImportRow(device.id, model_pos, declared, "agrees, not touched"))
|
|
145
|
+
continue
|
|
146
|
+
if not declared_too:
|
|
147
|
+
rows.append(
|
|
148
|
+
ImportRow(
|
|
149
|
+
device.id,
|
|
150
|
+
model_pos,
|
|
151
|
+
declared,
|
|
152
|
+
f"differs by {distance_mm:.1f} mm, not touched",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
continue
|
|
156
|
+
entries.append(
|
|
157
|
+
{
|
|
158
|
+
"path": f"devices/{device.id}/physical/mount_xyz",
|
|
159
|
+
"value": list(model_pos),
|
|
160
|
+
"unit": "m",
|
|
161
|
+
"source": source,
|
|
162
|
+
"witness": witness,
|
|
163
|
+
"note": note,
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
rows.append(
|
|
167
|
+
ImportRow(
|
|
168
|
+
device.id,
|
|
169
|
+
model_pos,
|
|
170
|
+
declared,
|
|
171
|
+
f"differs by {distance_mm:.1f} mm, written",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# No mount_xyz declared: the bulk-import path.
|
|
177
|
+
entries.append(
|
|
178
|
+
{
|
|
179
|
+
"path": f"devices/{device.id}/physical/mount_xyz",
|
|
180
|
+
"value": list(model_pos),
|
|
181
|
+
"unit": "m",
|
|
182
|
+
"source": source,
|
|
183
|
+
"witness": witness,
|
|
184
|
+
"note": note,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
if physical is None or physical.mount_frame is None:
|
|
188
|
+
entries.append(
|
|
189
|
+
{
|
|
190
|
+
"path": f"devices/{device.id}/physical/mount_frame",
|
|
191
|
+
"value": root,
|
|
192
|
+
"source": source,
|
|
193
|
+
"witness": witness,
|
|
194
|
+
"note": (
|
|
195
|
+
f"mount_frame set to the model's root link ({root}) from the "
|
|
196
|
+
f"model on {today}, confirmed by {source_person}"
|
|
197
|
+
),
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
rows.append(ImportRow(device.id, model_pos, None, "written"))
|
|
201
|
+
|
|
202
|
+
return ImportResult(
|
|
203
|
+
rows=tuple(rows),
|
|
204
|
+
entries=tuple(entries),
|
|
205
|
+
skipped_non_devices=skipped_non_devices,
|
|
206
|
+
root=root,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def write_change_file(entries: tuple[dict[str, Any], ...], out: Path, *, scene_source: str) -> None:
|
|
211
|
+
"""The single place this module writes bytes to disk — every witness and
|
|
212
|
+
every source in ``entries`` is validated here, before the file is opened, so no write
|
|
213
|
+
path around this module (direct library call included) can land a blank or "you"
|
|
214
|
+
witness, or an absolute source path.
|
|
215
|
+
"""
|
|
216
|
+
for witness in {entry["witness"] for entry in entries if "witness" in entry}:
|
|
217
|
+
validate_witness(
|
|
218
|
+
witness, refusal=f"--witness must name a real person, not {witness!r}"
|
|
219
|
+
)
|
|
220
|
+
for source in {str(entry["source"]) for entry in entries if "source" in entry}:
|
|
221
|
+
validate_source(source)
|
|
222
|
+
yaml = YAML()
|
|
223
|
+
yaml.default_flow_style = False
|
|
224
|
+
yaml.sort_base_mapping_type_on_output = False
|
|
225
|
+
today = dt.date.today().isoformat()
|
|
226
|
+
|
|
227
|
+
def _flow_lists(entry: dict[str, Any]) -> dict[str, Any]:
|
|
228
|
+
out_entry = dict(entry)
|
|
229
|
+
value = out_entry.get("value")
|
|
230
|
+
if isinstance(value, list):
|
|
231
|
+
seq = CommentedSeq(value)
|
|
232
|
+
seq.fa.set_flow_style()
|
|
233
|
+
out_entry["value"] = seq
|
|
234
|
+
return out_entry
|
|
235
|
+
|
|
236
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
237
|
+
prefix=f".{out.name}.", suffix=".tmp", dir=out.parent
|
|
238
|
+
)
|
|
239
|
+
temporary_path = Path(temporary_name)
|
|
240
|
+
try:
|
|
241
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
242
|
+
handle.write(f"# written by lockwire scene import — {scene_source}, {today}\n")
|
|
243
|
+
yaml.dump([_flow_lists(entry) for entry in entries], handle)
|
|
244
|
+
if out.exists():
|
|
245
|
+
_replace_then_backup(temporary_path, out, out.read_text(encoding="utf-8"))
|
|
246
|
+
else:
|
|
247
|
+
os.replace(temporary_path, out)
|
|
248
|
+
finally:
|
|
249
|
+
temporary_path.unlink(missing_ok=True)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@_locked_write
|
|
253
|
+
def write_and_apply_import(
|
|
254
|
+
contract_path: Path,
|
|
255
|
+
entries: tuple[dict[str, Any], ...],
|
|
256
|
+
out: Path,
|
|
257
|
+
*,
|
|
258
|
+
scene_source: str,
|
|
259
|
+
expected_revision: str | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Keep the scene-import shortcut's draft and contract under one revision lock."""
|
|
262
|
+
if expected_revision is None:
|
|
263
|
+
raise EditConflict("--write requires --expected-revision")
|
|
264
|
+
backup = out.with_name(out.name + ".bak")
|
|
265
|
+
if {out.resolve(), backup.resolve()} & {
|
|
266
|
+
contract_path.resolve(),
|
|
267
|
+
contract_path.with_name(contract_path.name + ".bak").resolve(),
|
|
268
|
+
}:
|
|
269
|
+
raise SceneImportRefusal("change file must differ from the contract and its backup")
|
|
270
|
+
|
|
271
|
+
changes = [Change(**entry) for entry in entries]
|
|
272
|
+
propose_changes(contract_path, changes)
|
|
273
|
+
prior_out = _backup_temp(out, out.read_bytes()) if out.exists() else None
|
|
274
|
+
try:
|
|
275
|
+
prior_backup = _backup_temp(backup, backup.read_bytes()) if backup.exists() else None
|
|
276
|
+
except BaseException:
|
|
277
|
+
if prior_out is not None:
|
|
278
|
+
prior_out.unlink(missing_ok=True)
|
|
279
|
+
raise
|
|
280
|
+
retain: set[Path] = set()
|
|
281
|
+
try:
|
|
282
|
+
write_change_file(entries, out, scene_source=scene_source)
|
|
283
|
+
try:
|
|
284
|
+
apply_changes(contract_path, changes, expected_revision=expected_revision)
|
|
285
|
+
except BaseException as apply_error:
|
|
286
|
+
failures: list[str] = []
|
|
287
|
+
for target, prior in ((out, prior_out), (backup, prior_backup)):
|
|
288
|
+
try:
|
|
289
|
+
if prior is None:
|
|
290
|
+
target.unlink(missing_ok=True)
|
|
291
|
+
else:
|
|
292
|
+
os.replace(prior, target)
|
|
293
|
+
except BaseException as restore_error:
|
|
294
|
+
if prior is not None:
|
|
295
|
+
retain.add(prior)
|
|
296
|
+
failures.append(f"{target}: {restore_error}")
|
|
297
|
+
if failures:
|
|
298
|
+
artifacts = (
|
|
299
|
+
", ".join(str(path) for path in sorted(retain)) or "none (prior file absent)"
|
|
300
|
+
)
|
|
301
|
+
raise OSError(
|
|
302
|
+
"scene import could not restore draft after contract failure; "
|
|
303
|
+
f"recovery bytes at {artifacts}; {'; '.join(failures)}"
|
|
304
|
+
) from apply_error
|
|
305
|
+
raise
|
|
306
|
+
finally:
|
|
307
|
+
for prior in (prior_out, prior_backup):
|
|
308
|
+
if prior is not None and prior not in retain:
|
|
309
|
+
prior.unlink(missing_ok=True)
|