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.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
@@ -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)