gitcad 0.7.7__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.
- gitcad/bench/__init__.py +0 -0
- gitcad/bench/corpus.py +260 -0
- gitcad/bench/scorecard.py +176 -0
- gitcad/bridge.py +72 -0
- gitcad/convert.py +30 -0
- gitcad/explore.py +99 -0
- gitcad/fasteners.py +112 -0
- gitcad/init.py +134 -0
- gitcad/lots.py +136 -0
- gitcad/mcp/__init__.py +15 -0
- gitcad/mcp/server.py +1065 -0
- gitcad/merge3.py +289 -0
- gitcad/pcba.py +114 -0
- gitcad/release.py +223 -0
- gitcad/render.py +177 -0
- gitcad/requirements.py +204 -0
- gitcad/review.py +230 -0
- gitcad/viewer/__init__.py +19 -0
- gitcad/viewer/boardsvg.py +80 -0
- gitcad/viewer/page.py +577 -0
- gitcad/viewer/server.py +433 -0
- gitcad-0.7.7.dist-info/METADATA +70 -0
- gitcad-0.7.7.dist-info/RECORD +25 -0
- gitcad-0.7.7.dist-info/WHEEL +4 -0
- gitcad-0.7.7.dist-info/entry_points.txt +11 -0
gitcad/merge3.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Semantic 3-way merge for design documents (ADR-0016).
|
|
2
|
+
|
|
3
|
+
Merge units are semantic, keyed by stable identity: features by id for
|
|
4
|
+
models, components by ref and connectivity by PIN for schematics. Both
|
|
5
|
+
sides touching different units merges cleanly; both sides changing the
|
|
6
|
+
same unit differently is a structured conflict — never a text marker
|
|
7
|
+
inside canonical JSON (that would make the file unparseable for every
|
|
8
|
+
tool including the resolver).
|
|
9
|
+
|
|
10
|
+
Result contract: ``{"ok": bool, "merged": text|None, "conflicts": [...]}``.
|
|
11
|
+
A clean merge's output is re-parsed through the document's own loader
|
|
12
|
+
before being returned — a merge that doesn't rebuild is a driver bug,
|
|
13
|
+
never shipped state.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
from gitcad.canonical import canonical_json
|
|
21
|
+
from gitcad.release import _kind
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cell3(base, ours, theirs):
|
|
25
|
+
"""Classic 3-way cell. Values are canonical strings (None = absent).
|
|
26
|
+
Returns ("take", value) or ("conflict", ours, theirs)."""
|
|
27
|
+
if ours == theirs:
|
|
28
|
+
return ("take", ours)
|
|
29
|
+
if ours == base:
|
|
30
|
+
return ("take", theirs)
|
|
31
|
+
if theirs == base:
|
|
32
|
+
return ("take", ours)
|
|
33
|
+
return ("conflict", ours, theirs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def merge_documents(base: str, ours: str, theirs: str) -> dict:
|
|
37
|
+
kind_b = _kind(base)
|
|
38
|
+
if not (_kind(ours) == _kind(theirs) == kind_b):
|
|
39
|
+
return {"ok": False, "merged": None, "conflicts": [
|
|
40
|
+
{"unit": "document", "reason": "kind changed between branches"}]}
|
|
41
|
+
if kind_b == "document":
|
|
42
|
+
return _merge_model(base, ours, theirs)
|
|
43
|
+
if kind_b == "schematic":
|
|
44
|
+
return _merge_schematic(base, ours, theirs)
|
|
45
|
+
if kind_b == "board":
|
|
46
|
+
return _merge_board(base, ours, theirs)
|
|
47
|
+
# parts: whole-document 3-way, honest coarse fallback (ADR-0016)
|
|
48
|
+
verdict = _cell3(base, ours, theirs)
|
|
49
|
+
if verdict[0] == "take":
|
|
50
|
+
return {"ok": True, "merged": verdict[1], "conflicts": []}
|
|
51
|
+
return {"ok": False, "merged": None, "conflicts": [
|
|
52
|
+
{"unit": kind_b, "reason": "both branches changed this document "
|
|
53
|
+
"(fine-grained merge for this kind is a later stage)"}]}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -- boards: refs/names are identity; copper's CONTENT is its identity --------
|
|
57
|
+
|
|
58
|
+
def _merge_board(base: str, ours: str, theirs: str) -> dict:
|
|
59
|
+
"""Fine-grained board merge (ADR-0016 upgrade):
|
|
60
|
+
|
|
61
|
+
- components by ref, mounting holes by name, net classes by class name —
|
|
62
|
+
classic 3-way cells (both changed differently = conflict);
|
|
63
|
+
- tracks/vias/zones have no stable id, and don't need one: their content
|
|
64
|
+
IS their identity, so they merge as element SETS — a moved track is a
|
|
65
|
+
removal plus an addition and merges cleanly; the only way to conflict
|
|
66
|
+
is at the named units above (or the outline, a whole-value cell);
|
|
67
|
+
- outline/thickness/mask_expansion: whole-value cells.
|
|
68
|
+
"""
|
|
69
|
+
from gitcad.ecad.board import Board
|
|
70
|
+
|
|
71
|
+
bb, bo, bt = (Board.loads(x) for x in (base, ours, theirs))
|
|
72
|
+
conflicts: list[dict] = []
|
|
73
|
+
|
|
74
|
+
def keyed_merge(unit: str, base_map: dict, ours_map: dict, theirs_map: dict,
|
|
75
|
+
canon) -> dict:
|
|
76
|
+
out = {}
|
|
77
|
+
for k in {**base_map, **ours_map, **theirs_map}:
|
|
78
|
+
verdict = _cell3(canon(base_map.get(k)), canon(ours_map.get(k)),
|
|
79
|
+
canon(theirs_map.get(k)))
|
|
80
|
+
if verdict[0] == "conflict":
|
|
81
|
+
side = lambda v: json.loads(v) if v else None # noqa: E731
|
|
82
|
+
conflicts.append({"unit": unit, "key": k,
|
|
83
|
+
"ours": side(verdict[1]),
|
|
84
|
+
"theirs": side(verdict[2])})
|
|
85
|
+
elif verdict[1] is not None:
|
|
86
|
+
out[k] = json.loads(verdict[1])
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
from dataclasses import asdict
|
|
90
|
+
|
|
91
|
+
canon_d = lambda x: canonical_json(asdict(x)) if x is not None else None # noqa: E731
|
|
92
|
+
comps = keyed_merge("component",
|
|
93
|
+
{c.ref: c for c in bb.components},
|
|
94
|
+
{c.ref: c for c in bo.components},
|
|
95
|
+
{c.ref: c for c in bt.components}, canon_d)
|
|
96
|
+
holes = keyed_merge("mounting_hole",
|
|
97
|
+
{m.name: m for m in bb.mounting_holes},
|
|
98
|
+
{m.name: m for m in bo.mounting_holes},
|
|
99
|
+
{m.name: m for m in bt.mounting_holes}, canon_d)
|
|
100
|
+
canon_j = lambda x: canonical_json(x) if x is not None else None # noqa: E731
|
|
101
|
+
classes = keyed_merge("net_class", bb.net_classes, bo.net_classes,
|
|
102
|
+
bt.net_classes, canon_j)
|
|
103
|
+
|
|
104
|
+
def element_set(unit: str, base_l: list, ours_l: list, theirs_l: list) -> list:
|
|
105
|
+
b_set = {canonical_json(asdict(x)) for x in base_l}
|
|
106
|
+
o_set = {canonical_json(asdict(x)) for x in ours_l}
|
|
107
|
+
t_set = {canonical_json(asdict(x)) for x in theirs_l}
|
|
108
|
+
# element-wise 3-way: keep if (in base and neither side removed it)
|
|
109
|
+
# or (added by either side)
|
|
110
|
+
kept = ((b_set & o_set & t_set)
|
|
111
|
+
| (o_set - b_set) | (t_set - b_set))
|
|
112
|
+
return [json.loads(x) for x in sorted(kept)]
|
|
113
|
+
|
|
114
|
+
tracks = element_set("track", bb.tracks, bo.tracks, bt.tracks)
|
|
115
|
+
vias = element_set("via", bb.vias, bo.vias, bt.vias)
|
|
116
|
+
zones = element_set("zone", bb.zones, bo.zones, bt.zones)
|
|
117
|
+
|
|
118
|
+
scalars = {}
|
|
119
|
+
for attr in ("name", "outline", "thickness", "mask_expansion"):
|
|
120
|
+
verdict = _cell3(canonical_json(getattr(bb, attr)),
|
|
121
|
+
canonical_json(getattr(bo, attr)),
|
|
122
|
+
canonical_json(getattr(bt, attr)))
|
|
123
|
+
if verdict[0] == "conflict":
|
|
124
|
+
conflicts.append({"unit": attr,
|
|
125
|
+
"ours": json.loads(verdict[1]),
|
|
126
|
+
"theirs": json.loads(verdict[2])})
|
|
127
|
+
else:
|
|
128
|
+
scalars[attr] = json.loads(verdict[1])
|
|
129
|
+
|
|
130
|
+
if conflicts:
|
|
131
|
+
return {"ok": False, "merged": None, "conflicts": conflicts}
|
|
132
|
+
|
|
133
|
+
merged_doc = {"schema": Board.SCHEMA, "board": {
|
|
134
|
+
**scalars,
|
|
135
|
+
"components": [comps[k] for k in sorted(comps)],
|
|
136
|
+
"tracks": tracks, "vias": vias, "zones": zones,
|
|
137
|
+
"mounting_holes": [holes[k] for k in sorted(holes)],
|
|
138
|
+
"net_classes": classes,
|
|
139
|
+
}}
|
|
140
|
+
text = canonical_json(merged_doc, indent=2) + "\n"
|
|
141
|
+
Board.loads(text) # the rebuild gate
|
|
142
|
+
return {"ok": True, "merged": text, "conflicts": []}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# -- models: features by stable id --------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _merge_model(base: str, ours: str, theirs: str) -> dict:
|
|
148
|
+
from gitcad.document import Document
|
|
149
|
+
|
|
150
|
+
b = {f.id: f for f in Document.loads(base).features}
|
|
151
|
+
o = {f.id: f for f in Document.loads(ours).features}
|
|
152
|
+
t = {f.id: f for f in Document.loads(theirs).features}
|
|
153
|
+
canon = lambda f: canonical_json(f.to_dict()) if f else None # noqa: E731
|
|
154
|
+
|
|
155
|
+
conflicts: list[dict] = []
|
|
156
|
+
keep: dict[str, dict] = {}
|
|
157
|
+
for fid in {**b, **o, **t}:
|
|
158
|
+
verdict = _cell3(canon(b.get(fid)), canon(o.get(fid)), canon(t.get(fid)))
|
|
159
|
+
if verdict[0] == "conflict":
|
|
160
|
+
side = lambda v: json.loads(v) if v else None # noqa: E731
|
|
161
|
+
conflicts.append({"unit": "feature", "id": fid,
|
|
162
|
+
"op": (o.get(fid) or t.get(fid) or b.get(fid)).op,
|
|
163
|
+
"ours": side(verdict[1]), "theirs": side(verdict[2])})
|
|
164
|
+
elif verdict[1] is not None:
|
|
165
|
+
keep[fid] = json.loads(verdict[1])
|
|
166
|
+
if conflicts:
|
|
167
|
+
return {"ok": False, "merged": None, "conflicts": conflicts}
|
|
168
|
+
|
|
169
|
+
# Order: base survivors, then ours-added, then theirs-added…
|
|
170
|
+
order = [fid for fid in b if fid in keep]
|
|
171
|
+
order += [fid for fid in o if fid in keep and fid not in b]
|
|
172
|
+
order += [fid for fid in t if fid in keep and fid not in b and fid not in o]
|
|
173
|
+
# …then a stable topological pass so inputs precede their users.
|
|
174
|
+
placed: list[str] = []
|
|
175
|
+
ready: set[str] = set()
|
|
176
|
+
pending = list(order)
|
|
177
|
+
while pending:
|
|
178
|
+
progressed = False
|
|
179
|
+
for fid in list(pending):
|
|
180
|
+
if all(ref in ready for ref in keep[fid].get("inputs", [])):
|
|
181
|
+
placed.append(fid)
|
|
182
|
+
ready.add(fid)
|
|
183
|
+
pending.remove(fid)
|
|
184
|
+
progressed = True
|
|
185
|
+
if not progressed:
|
|
186
|
+
return {"ok": False, "merged": None, "conflicts": [
|
|
187
|
+
{"unit": "feature", "id": pending[0],
|
|
188
|
+
"reason": "input dependency cycle or input deleted by the "
|
|
189
|
+
"other branch"}]}
|
|
190
|
+
merged_text = canonical_json(
|
|
191
|
+
{"schema": "gitcad/document@1",
|
|
192
|
+
"features": [keep[fid] for fid in placed]}, indent=2) + "\n"
|
|
193
|
+
Document.loads(merged_text) # the rebuild gate: must parse or we crash here
|
|
194
|
+
return {"ok": True, "merged": merged_text, "conflicts": []}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# -- schematics: components by ref, connectivity by pin -----------------------
|
|
198
|
+
|
|
199
|
+
def _pin_map(sch) -> dict[str, str]:
|
|
200
|
+
return {pr: net for net, prs in sch.nets.items() for pr in prs}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _merge_schematic(base: str, ours: str, theirs: str) -> dict:
|
|
204
|
+
from gitcad.ecad.schematic import Schematic
|
|
205
|
+
|
|
206
|
+
sb, so, st = (Schematic.loads(x) for x in (base, ours, theirs))
|
|
207
|
+
canon = lambda c: canonical_json( # noqa: E731
|
|
208
|
+
{"ref": c.ref, "value": c.value, "footprint": c.footprint,
|
|
209
|
+
"pins": [p.__dict__ for p in c.pins], "attrs": c.attrs}) if c else None
|
|
210
|
+
|
|
211
|
+
conflicts: list[dict] = []
|
|
212
|
+
comps: dict[str, dict] = {}
|
|
213
|
+
cb = {c.ref: c for c in sb.components}
|
|
214
|
+
co = {c.ref: c for c in so.components}
|
|
215
|
+
ct = {c.ref: c for c in st.components}
|
|
216
|
+
for ref in {**cb, **co, **ct}:
|
|
217
|
+
verdict = _cell3(canon(cb.get(ref)), canon(co.get(ref)), canon(ct.get(ref)))
|
|
218
|
+
if verdict[0] == "conflict":
|
|
219
|
+
side = lambda v: json.loads(v) if v else None # noqa: E731
|
|
220
|
+
conflicts.append({"unit": "component", "ref": ref,
|
|
221
|
+
"ours": side(verdict[1]), "theirs": side(verdict[2])})
|
|
222
|
+
elif verdict[1] is not None:
|
|
223
|
+
comps[ref] = json.loads(verdict[1])
|
|
224
|
+
|
|
225
|
+
pb, po, pt = _pin_map(sb), _pin_map(so), _pin_map(st)
|
|
226
|
+
pins: dict[str, str] = {}
|
|
227
|
+
for pr in {**pb, **po, **pt}:
|
|
228
|
+
verdict = _cell3(pb.get(pr), po.get(pr), pt.get(pr))
|
|
229
|
+
if verdict[0] == "conflict":
|
|
230
|
+
conflicts.append({"unit": "pin", "pin": pr,
|
|
231
|
+
"ours": verdict[1], "theirs": verdict[2]})
|
|
232
|
+
elif verdict[1] is not None and pr.split(".", 1)[0] in comps:
|
|
233
|
+
pins[pr] = verdict[1]
|
|
234
|
+
|
|
235
|
+
specs: dict[str, dict] = {}
|
|
236
|
+
for key in {**sb.net_specs, **so.net_specs, **st.net_specs}:
|
|
237
|
+
cj = lambda d: canonical_json(d) if d is not None else None # noqa: E731
|
|
238
|
+
verdict = _cell3(cj(sb.net_specs.get(key)), cj(so.net_specs.get(key)),
|
|
239
|
+
cj(st.net_specs.get(key)))
|
|
240
|
+
if verdict[0] == "conflict":
|
|
241
|
+
conflicts.append({"unit": "net_spec", "net": key,
|
|
242
|
+
"ours": verdict[1], "theirs": verdict[2]})
|
|
243
|
+
elif verdict[1] is not None:
|
|
244
|
+
specs[key] = json.loads(verdict[1])
|
|
245
|
+
|
|
246
|
+
if conflicts:
|
|
247
|
+
return {"ok": False, "merged": None, "conflicts": conflicts}
|
|
248
|
+
|
|
249
|
+
name3 = _cell3(sb.name, so.name, st.name)
|
|
250
|
+
merged = Schematic(name=name3[1] if name3[0] == "take" else so.name)
|
|
251
|
+
from gitcad.ecad.schematic import Pin, SchComponent
|
|
252
|
+
|
|
253
|
+
for ref in sorted(comps):
|
|
254
|
+
c = comps[ref]
|
|
255
|
+
merged.components.append(SchComponent(
|
|
256
|
+
ref=c["ref"], value=c["value"], footprint=c["footprint"],
|
|
257
|
+
pins=[Pin(**p) for p in c["pins"]], attrs=c["attrs"]))
|
|
258
|
+
for pr, net in pins.items():
|
|
259
|
+
merged.connect(net, pr)
|
|
260
|
+
merged.net_specs = specs
|
|
261
|
+
text = merged.dumps()
|
|
262
|
+
Schematic.loads(text) # the rebuild gate
|
|
263
|
+
return {"ok": True, "merged": text, "conflicts": []}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main() -> None: # pragma: no cover - git merge driver entrypoint
|
|
267
|
+
"""git merge driver: gitcad-merge %O %A %B — result written to %A.
|
|
268
|
+
|
|
269
|
+
.gitattributes: *.gitcad.json merge=gitcad
|
|
270
|
+
.git/config: [merge "gitcad"]
|
|
271
|
+
name = gitcad semantic merge
|
|
272
|
+
driver = gitcad-merge %O %A %B
|
|
273
|
+
"""
|
|
274
|
+
import sys
|
|
275
|
+
from pathlib import Path
|
|
276
|
+
|
|
277
|
+
base_p, ours_p, theirs_p = (Path(p) for p in sys.argv[1:4])
|
|
278
|
+
result = merge_documents(base_p.read_text(encoding="utf-8"),
|
|
279
|
+
ours_p.read_text(encoding="utf-8"),
|
|
280
|
+
theirs_p.read_text(encoding="utf-8"))
|
|
281
|
+
if result["ok"]:
|
|
282
|
+
ours_p.write_text(result["merged"], encoding="utf-8")
|
|
283
|
+
sys.exit(0)
|
|
284
|
+
report = ours_p.with_suffix(ours_p.suffix + ".gitcad-conflict.json")
|
|
285
|
+
report.write_text(canonical_json({"conflicts": result["conflicts"]},
|
|
286
|
+
indent=2) + "\n", encoding="utf-8")
|
|
287
|
+
print(f"gitcad-merge: {len(result['conflicts'])} semantic conflict(s) — "
|
|
288
|
+
f"see {report.name}", file=sys.stderr)
|
|
289
|
+
sys.exit(1)
|
gitcad/pcba.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""PCBA parts — the Fusion-360 duality, gitcad-style.
|
|
2
|
+
|
|
3
|
+
At the assembly level a PCBA is a mechanical part: envelope, mounting-hole
|
|
4
|
+
ports, a 3D body. ``pcba_verify`` is what "entering" it means for checks —
|
|
5
|
+
one call runs the complete electrical workflow gate over the part's
|
|
6
|
+
referenced sources:
|
|
7
|
+
|
|
8
|
+
- ERC + electrical envelopes (ADR-0015) per schematic
|
|
9
|
+
- board validation + DRC + copper connectivity
|
|
10
|
+
- schematic↔board parity (the ECO check) for every schematic
|
|
11
|
+
|
|
12
|
+
The part manifest is the front door; the electrical truth lives in the
|
|
13
|
+
files it references, exactly like a mech part references its .model.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from gitcad.errors import GitcadError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_pcba(part_text: str) -> bool:
|
|
25
|
+
try:
|
|
26
|
+
doc = json.loads(part_text)
|
|
27
|
+
except Exception:
|
|
28
|
+
return False
|
|
29
|
+
return (doc.get("schema", "").startswith("gitcad/part")
|
|
30
|
+
and (doc.get("body") or {}).get("kind") == "pcba")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pcba_sources(part_text: str, root: str) -> dict:
|
|
34
|
+
"""Resolve the PCBA's referenced files: {"board": Path, "schematics": [Path]}."""
|
|
35
|
+
from gitcad.part import PartManifest
|
|
36
|
+
|
|
37
|
+
part = PartManifest.loads(part_text)
|
|
38
|
+
body = part.body or {}
|
|
39
|
+
if body.get("kind") != "pcba":
|
|
40
|
+
raise GitcadError(f"part {part.name!r} is not a pcba (body.kind="
|
|
41
|
+
f"{body.get('kind')!r})")
|
|
42
|
+
rootp = Path(root)
|
|
43
|
+
board = rootp / body.get("board", "")
|
|
44
|
+
if not body.get("board") or not board.is_file():
|
|
45
|
+
raise GitcadError(f"pcba board file missing: {body.get('board')!r}")
|
|
46
|
+
schematics = []
|
|
47
|
+
for rel in body.get("schematics", []):
|
|
48
|
+
p = rootp / rel
|
|
49
|
+
if not p.is_file():
|
|
50
|
+
raise GitcadError(f"pcba schematic file missing: {rel!r}")
|
|
51
|
+
schematics.append(p)
|
|
52
|
+
return {"part": part, "board": board, "schematics": schematics}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pcba_verify(part_text: str, root: str) -> dict:
|
|
56
|
+
"""The electrical workflow's gate, as one call.
|
|
57
|
+
|
|
58
|
+
Multi-schematic semantics: a board's netlist can span many sheets, so
|
|
59
|
+
ERC, envelopes, and parity run on the MERGED system schematic (nets
|
|
60
|
+
union by name — the cross-sheet contract, ADR proved on the real
|
|
61
|
+
4-sheet Altair where per-sheet checks flag inter-sheet signals as
|
|
62
|
+
false positives). Per-sheet checks would lie; the merged system is
|
|
63
|
+
the electrical truth of this PCBA. Coverage is honest: zero referenced
|
|
64
|
+
schematics is visible, never silently green."""
|
|
65
|
+
from gitcad.ecad import (Board, Schematic, board_parity, check_connectivity,
|
|
66
|
+
check_envelopes, merge_schematics)
|
|
67
|
+
from gitcad.ecad.drc import run_drc
|
|
68
|
+
|
|
69
|
+
src = pcba_sources(part_text, root)
|
|
70
|
+
board = Board.loads(src["board"].read_text(encoding="utf-8"))
|
|
71
|
+
checks: dict = {}
|
|
72
|
+
violations: list[str] = []
|
|
73
|
+
|
|
74
|
+
r = board.validate()
|
|
75
|
+
checks["board:validate"] = "ok" if r.ok else "FAIL"
|
|
76
|
+
violations += [f"board:{v}" for v in r.violations]
|
|
77
|
+
d = run_drc(board)
|
|
78
|
+
checks["board:drc"] = "ok" if d.ok else "FAIL"
|
|
79
|
+
violations += [f"drc:{v}" for v in d.violations]
|
|
80
|
+
c = check_connectivity(board)
|
|
81
|
+
checks["board:connectivity"] = "ok" if c.ok else "FAIL"
|
|
82
|
+
violations += [f"connectivity:{v}" for v in c.violations]
|
|
83
|
+
|
|
84
|
+
sheets = [Schematic.loads(p.read_text(encoding="utf-8"))
|
|
85
|
+
for p in src["schematics"]]
|
|
86
|
+
if sheets:
|
|
87
|
+
system = sheets[0] if len(sheets) == 1 else \
|
|
88
|
+
merge_schematics(f"{src['part'].name}-system", sheets)
|
|
89
|
+
e = system.erc()
|
|
90
|
+
checks["system:erc"] = "ok" if e.ok else "FAIL"
|
|
91
|
+
violations += [f"erc:{v}" for v in e.violations]
|
|
92
|
+
env = check_envelopes(system)
|
|
93
|
+
checks["system:envelope"] = "ok" if env.ok else "FAIL"
|
|
94
|
+
violations += [f"envelope:{v}" for v in env.violations]
|
|
95
|
+
p = board_parity(system, board)
|
|
96
|
+
checks["system:parity"] = "ok" if p.ok else "FAIL"
|
|
97
|
+
violations += [f"parity:{v}" for v in p.violations]
|
|
98
|
+
|
|
99
|
+
checks["schematics_checked"] = len(sheets)
|
|
100
|
+
|
|
101
|
+
# waivers: <partname>.waivers next to the part — suppression that shows
|
|
102
|
+
waivers_file = Path(root) / f"{src['part'].name}.waivers"
|
|
103
|
+
waived: list[dict] = []
|
|
104
|
+
if waivers_file.is_file():
|
|
105
|
+
from gitcad.waivers import load_waivers, waive
|
|
106
|
+
|
|
107
|
+
ws = load_waivers(waivers_file.read_text(encoding="utf-8"))
|
|
108
|
+
violations, waived, unused = waive(violations, ws)
|
|
109
|
+
checks["waived"] = len(waived)
|
|
110
|
+
if unused:
|
|
111
|
+
violations += [f"waiver-unused:{m}" for m in unused]
|
|
112
|
+
|
|
113
|
+
return {"ok": not violations, "part": src["part"].name,
|
|
114
|
+
"checks": checks, "violations": violations, "waived": waived}
|
gitcad/release.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Release machinery + semantic diff — the git-native thesis, completed.
|
|
2
|
+
|
|
3
|
+
``release()`` is Project-Releaser-as-code (feature-map A8/B5): run EVERY
|
|
4
|
+
check the project defines — model validation, ERC, schematic-board parity,
|
|
5
|
+
DRC, fab-readiness — and only on all-green produce the immutable artifact
|
|
6
|
+
set (STEP/STL/drawings for models, Gerber/drill/PnP for boards) plus a
|
|
7
|
+
manifest pinning every input and output by sha256. A release either passes
|
|
8
|
+
everything or it does not exist; there is no "release with known failures".
|
|
9
|
+
|
|
10
|
+
``semantic_diff()`` is the PR review surface ADR-0004 promised: not a text
|
|
11
|
+
diff but a *meaning* diff — features added/removed/changed by stable id,
|
|
12
|
+
volume delta from real geometry, board item deltas, and for parts the
|
|
13
|
+
interface-semver classification with the required version bump.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from gitcad.canonical import canonical_json
|
|
24
|
+
from gitcad.document import Document
|
|
25
|
+
from gitcad.errors import GitcadError
|
|
26
|
+
from gitcad.kernel import get_kernel
|
|
27
|
+
from gitcad.seams import Kernel
|
|
28
|
+
|
|
29
|
+
from gitcad._version import __version__ as _gitcad_version
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _kind(text: str) -> str:
|
|
33
|
+
schema = json.loads(text).get("schema", "")
|
|
34
|
+
for k in ("document", "board", "schematic", "part"):
|
|
35
|
+
if schema.startswith(f"gitcad/{k}"):
|
|
36
|
+
return k
|
|
37
|
+
raise GitcadError(f"unknown schema {schema!r}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sha(path: Path) -> str:
|
|
41
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# -- semantic diff ------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def semantic_diff(old_text: str, new_text: str, kernel: Kernel | None = None) -> dict:
|
|
47
|
+
"""A meaning-level diff between two revisions of the same document."""
|
|
48
|
+
kind_old, kind_new = _kind(old_text), _kind(new_text)
|
|
49
|
+
if kind_old != kind_new:
|
|
50
|
+
return {"kind": "mixed", "error": f"cannot diff {kind_old} against {kind_new}"}
|
|
51
|
+
out: dict = {"kind": kind_old}
|
|
52
|
+
|
|
53
|
+
if kind_old == "document":
|
|
54
|
+
old, new = Document.loads(old_text), Document.loads(new_text)
|
|
55
|
+
old_ids = {f.id: f for f in old.features}
|
|
56
|
+
new_ids = {f.id: f for f in new.features}
|
|
57
|
+
out["features_added"] = [{"id": i, "op": new_ids[i].op} for i in new_ids if i not in old_ids]
|
|
58
|
+
out["features_removed"] = [{"id": i, "op": old_ids[i].op} for i in old_ids if i not in new_ids]
|
|
59
|
+
out["features_changed"] = [
|
|
60
|
+
{"id": i, "op": new_ids[i].op}
|
|
61
|
+
for i in new_ids
|
|
62
|
+
if i in old_ids and canonical_json(new_ids[i].to_dict()) != canonical_json(old_ids[i].to_dict())
|
|
63
|
+
]
|
|
64
|
+
kernel = kernel or get_kernel()
|
|
65
|
+
if not kernel.name.startswith("null"):
|
|
66
|
+
try:
|
|
67
|
+
v_old = kernel.measure(old.build(kernel).final(old))["volume"] if len(old) else 0.0
|
|
68
|
+
v_new = kernel.measure(new.build(kernel).final(new))["volume"] if len(new) else 0.0
|
|
69
|
+
out["volume_mm3"] = {"old": round(v_old, 3), "new": round(v_new, 3),
|
|
70
|
+
"delta": round(v_new - v_old, 3)}
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
out["volume_mm3"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
73
|
+
|
|
74
|
+
elif kind_old == "board":
|
|
75
|
+
from gitcad.ecad import Board
|
|
76
|
+
|
|
77
|
+
old_b, new_b = Board.loads(old_text), Board.loads(new_text)
|
|
78
|
+
old_refs = {c.ref for c in old_b.components}
|
|
79
|
+
new_refs = {c.ref for c in new_b.components}
|
|
80
|
+
out["components_added"] = sorted(new_refs - old_refs)
|
|
81
|
+
out["components_removed"] = sorted(old_refs - new_refs)
|
|
82
|
+
out["tracks"] = {"old": len(old_b.tracks), "new": len(new_b.tracks)}
|
|
83
|
+
out["vias"] = {"old": len(old_b.vias), "new": len(new_b.vias)}
|
|
84
|
+
out["outline_changed"] = old_b.outline != new_b.outline
|
|
85
|
+
|
|
86
|
+
elif kind_old == "part":
|
|
87
|
+
from gitcad.part import PartManifest, classify_change
|
|
88
|
+
|
|
89
|
+
old_p, new_p = PartManifest.loads(old_text), PartManifest.loads(new_text)
|
|
90
|
+
bump, reasons = classify_change(old_p.interface, new_p.interface)
|
|
91
|
+
out["required_bump"] = bump
|
|
92
|
+
out["reasons"] = reasons
|
|
93
|
+
out["version"] = {"old": old_p.version, "new": new_p.version}
|
|
94
|
+
|
|
95
|
+
else: # schematic
|
|
96
|
+
from gitcad.ecad import Schematic
|
|
97
|
+
|
|
98
|
+
old_s, new_s = Schematic.loads(old_text), Schematic.loads(new_text)
|
|
99
|
+
out["components_added"] = sorted({c.ref for c in new_s.components}
|
|
100
|
+
- {c.ref for c in old_s.components})
|
|
101
|
+
out["components_removed"] = sorted({c.ref for c in old_s.components}
|
|
102
|
+
- {c.ref for c in new_s.components})
|
|
103
|
+
out["nets"] = {"old": len(old_s.nets), "new": len(new_s.nets)}
|
|
104
|
+
|
|
105
|
+
out["identical"] = old_text == new_text
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# -- release ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class ReleaseResult:
|
|
113
|
+
version: str
|
|
114
|
+
ok: bool
|
|
115
|
+
checks: dict = field(default_factory=dict)
|
|
116
|
+
failures: list[str] = field(default_factory=list)
|
|
117
|
+
artifacts: dict[str, str] = field(default_factory=dict) # name -> sha256
|
|
118
|
+
manifest_path: str = ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def release(sources: list[str], outdir: str, version: str) -> ReleaseResult:
|
|
122
|
+
"""Check everything, then (and only then) produce the artifact set."""
|
|
123
|
+
out = Path(outdir)
|
|
124
|
+
result = ReleaseResult(version=version, ok=False)
|
|
125
|
+
kernel = None
|
|
126
|
+
|
|
127
|
+
docs: list[tuple[Path, str, str]] = [] # (path, kind, text)
|
|
128
|
+
for src in sources:
|
|
129
|
+
p = Path(src)
|
|
130
|
+
text = p.read_text(encoding="utf-8")
|
|
131
|
+
docs.append((p, _kind(text), text))
|
|
132
|
+
|
|
133
|
+
schematics = [(p, t) for p, k, t in docs if k == "schematic"]
|
|
134
|
+
boards = [(p, t) for p, k, t in docs if k == "board"]
|
|
135
|
+
|
|
136
|
+
# -- phase 1: every check must pass ---------------------------------------
|
|
137
|
+
for p, kind, text in docs:
|
|
138
|
+
label = p.name
|
|
139
|
+
if kind == "document":
|
|
140
|
+
kernel = kernel or get_kernel(require="occt")
|
|
141
|
+
doc = Document.loads(text)
|
|
142
|
+
build = doc.build(kernel)
|
|
143
|
+
for fid, shape in build.shapes.items():
|
|
144
|
+
r = kernel.validate(shape)
|
|
145
|
+
if not r.ok:
|
|
146
|
+
result.failures.append(f"{label}:validate:{fid}:{r.violations}")
|
|
147
|
+
result.checks[f"{label}:validate"] = "ok"
|
|
148
|
+
elif kind == "board":
|
|
149
|
+
from gitcad.ecad import Board, check_connectivity, run_drc
|
|
150
|
+
|
|
151
|
+
board = Board.loads(text)
|
|
152
|
+
r = board.validate()
|
|
153
|
+
if not r.ok:
|
|
154
|
+
result.failures.extend(f"{label}:fab:{v}" for v in r.violations)
|
|
155
|
+
d = run_drc(board)
|
|
156
|
+
if not d.ok:
|
|
157
|
+
result.failures.extend(f"{label}:drc:{v}" for v in d.violations)
|
|
158
|
+
c = check_connectivity(board)
|
|
159
|
+
if not c.ok:
|
|
160
|
+
result.failures.extend(f"{label}:connectivity:{v}" for v in c.violations)
|
|
161
|
+
result.checks[f"{label}:fab"] = "ok" if r.ok else "FAIL"
|
|
162
|
+
result.checks[f"{label}:drc"] = "ok" if d.ok else "FAIL"
|
|
163
|
+
result.checks[f"{label}:connectivity"] = "ok" if c.ok else "FAIL"
|
|
164
|
+
elif kind == "schematic":
|
|
165
|
+
from gitcad.ecad import Schematic
|
|
166
|
+
|
|
167
|
+
r = Schematic.loads(text).erc()
|
|
168
|
+
if not r.ok:
|
|
169
|
+
result.failures.extend(f"{label}:erc:{v}" for v in r.violations)
|
|
170
|
+
result.checks[f"{label}:erc"] = "ok" if r.ok else "FAIL"
|
|
171
|
+
|
|
172
|
+
# cross-checks: every schematic against every board (parity)
|
|
173
|
+
if schematics and boards:
|
|
174
|
+
from gitcad.ecad import Board, Schematic, board_parity
|
|
175
|
+
|
|
176
|
+
for sp, st in schematics:
|
|
177
|
+
for bp, bt in boards:
|
|
178
|
+
r = board_parity(Schematic.loads(st), Board.loads(bt))
|
|
179
|
+
key = f"parity:{sp.name}<->{bp.name}"
|
|
180
|
+
result.checks[key] = "ok" if r.ok else "FAIL"
|
|
181
|
+
if not r.ok:
|
|
182
|
+
result.failures.extend(f"{key}:{v}" for v in r.violations)
|
|
183
|
+
|
|
184
|
+
if result.failures:
|
|
185
|
+
return result # no artifacts on red — a release either passes or isn't
|
|
186
|
+
|
|
187
|
+
# -- phase 2: artifacts ----------------------------------------------------
|
|
188
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
for p, kind, text in docs:
|
|
190
|
+
stem = p.name.replace(".gitcad.json", "").replace(".json", "")
|
|
191
|
+
if kind == "document":
|
|
192
|
+
from gitcad.drawing import make_drawing
|
|
193
|
+
|
|
194
|
+
doc = Document.loads(text)
|
|
195
|
+
shape = doc.build(kernel).final(doc)
|
|
196
|
+
step = out / f"{stem}.step"
|
|
197
|
+
kernel.export_step(shape, str(step))
|
|
198
|
+
stl = out / f"{stem}.stl"
|
|
199
|
+
kernel.export_stl(shape, str(stl))
|
|
200
|
+
pdf = out / f"{stem}.pdf"
|
|
201
|
+
pdf.write_bytes(make_drawing(shape, kernel, title=f"{stem} {version}").to_pdf())
|
|
202
|
+
for f in (step, stl, pdf):
|
|
203
|
+
result.artifacts[f.name] = _sha(f)
|
|
204
|
+
elif kind == "board":
|
|
205
|
+
from gitcad.ecad import Board, export_fab
|
|
206
|
+
|
|
207
|
+
files = export_fab(Board.loads(text), str(out / f"{stem}-fab"))
|
|
208
|
+
for _, fpath in files.items():
|
|
209
|
+
fp = Path(fpath)
|
|
210
|
+
result.artifacts[f"{stem}-fab/{fp.name}"] = _sha(fp)
|
|
211
|
+
|
|
212
|
+
manifest = {
|
|
213
|
+
"generator": f"gitcad {_gitcad_version}",
|
|
214
|
+
"version": version,
|
|
215
|
+
"sources": {p.name: hashlib.sha256(t.encode()).hexdigest() for p, _, t in docs},
|
|
216
|
+
"checks": result.checks,
|
|
217
|
+
"artifacts": result.artifacts,
|
|
218
|
+
}
|
|
219
|
+
mpath = out / "release-manifest.json"
|
|
220
|
+
mpath.write_text(canonical_json(manifest, indent=2) + "\n", newline="\n", encoding="utf-8")
|
|
221
|
+
result.manifest_path = str(mpath)
|
|
222
|
+
result.ok = True
|
|
223
|
+
return result
|