ecadlib 0.3.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 (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/eval/golden.py ADDED
@@ -0,0 +1,446 @@
1
+ """Golden output snapshots: normalised fingerprints of the KiCad and Altium files ecadlib writes (`ecadlib eval golden`).
2
+
3
+ ecadlib eval golden rebuild the parts and compare with ecadlib/eval/golden/*.txt (exit 1 + diff on a change)
4
+ ecadlib eval golden --update rewrite the snapshots (commit them: a deliberate change is one reviewed diff)
5
+ ecadlib eval golden --group specs one group only (testpack, demo, specs)
6
+
7
+ Groups (all offline, no API key):
8
+ testpack the six parts of `ecadlib testpack` (librarian `standard` + the pack's KiCad library, STEP bodies on)
9
+ demo the bundled demo BOM through Workspace.librarian_run + kicad_dblib (`ecadlib demo`)
10
+ specs footprint_gen / footprint_adv on every package_specs/*.json, advanced/*.json and every 20th entry of the
11
+ standard table, written the librarian's way (kicad_import.convert_footprint -> altium_lib.write_pcblib)
12
+
13
+ A fingerprint is text, one line per thing that matters, so a diff reads like a review: footprint properties, every pad
14
+ (number, type, shape, position, size, drill, layers, rotation), graphics per layer as count + bounding box + hash,
15
+ 3D model; symbol fields and every pin (number, name, type, position, length); Altium pads in mm and every Altium
16
+ parameter; DbLib / database-library rows. Left out, because they are not geometry or fields: uuids / tstamps,
17
+ generator stamps, Altium UNIQUEIDs, GUIDs, MODELID / MODEL.CHECKSUM, file-header DATE / TIME (Altium files are read
18
+ back with altium_lib's reader, so the OLE container's timestamps never enter). Numbers are rounded to 4 decimals."""
19
+ import argparse, csv, difflib, hashlib, io, json, os, re, sqlite3, sys, tempfile
20
+ from pathlib import Path
21
+
22
+ HERE = Path(__file__).parent / "golden"
23
+ GROUPS = ("testpack", "demo", "specs")
24
+ SPEC_STRIDE = 20 # every 20th standard-table entry (sorted names): 31 of 615
25
+ DROP_HEADS = {"uuid", "tstamp", "generator", "generator_version"}
26
+ GRAPHICS = {"fp_line", "fp_rect", "fp_circle", "fp_arc", "fp_poly", "fp_curve", "rectangle", "polyline", "circle", "arc",
27
+ "bezier", "gr_line", "gr_rect", "gr_circle", "gr_arc", "gr_poly"}
28
+ VOLATILE = re.compile(r"(UNIQUEID|GUID|MODELID|CHECKSUM|^DATE$|^TIME$)", re.I)
29
+ NUM = re.compile(r"^-?\d+(\.\d+)?(e-?\d+)?$")
30
+ BARE = re.compile(r"^[A-Za-z0-9_.*&+:/-]+$")
31
+
32
+
33
+ # ---------------------------------------------------------------- normalising
34
+ def n(v):
35
+ """A number as short stable text (4 decimals, no trailing zeros, no -0)."""
36
+ x = round(float(v), 4)
37
+ if x == 0:
38
+ x = 0.0
39
+ s = f"{x:.4f}".rstrip("0").rstrip(".")
40
+ return s
41
+
42
+
43
+ def _atom(a):
44
+ a = str(a)
45
+ if NUM.match(a):
46
+ return n(a)
47
+ return a if BARE.match(a) else json.dumps(a)
48
+
49
+
50
+ def canon(node):
51
+ """S-expression -> one line, uuids / tstamps dropped, numbers normalised."""
52
+ if not isinstance(node, list):
53
+ return _atom(node)
54
+ return "(" + " ".join(canon(c) for c in node if not (isinstance(c, list) and c and c[0] in DROP_HEADS)) + ")"
55
+
56
+
57
+ def _points(node, out):
58
+ if isinstance(node, list):
59
+ if node and node[0] in ("start", "end", "mid", "center", "xy", "at") and len(node) >= 3:
60
+ try:
61
+ out.append((float(node[1]), float(node[2])))
62
+ except ValueError:
63
+ pass
64
+ for c in node[1:]:
65
+ _points(c, out)
66
+ return out
67
+
68
+
69
+ def _layer(node):
70
+ from .. import sexpr
71
+ l = sexpr.first(node, "layer")
72
+ return str(l[1]) if l and len(l) > 1 else "-"
73
+
74
+
75
+ def _summary(kind, items, layer_of):
76
+ """Graphics of one kind per layer: count, bounding box, hash of their canonical text."""
77
+ groups = {}
78
+ for it in items:
79
+ groups.setdefault(layer_of(it), []).append(canon(it) if isinstance(it, list) else it)
80
+ out = []
81
+ for lay, cs in sorted(groups.items()):
82
+ pts = [p for it in items if layer_of(it) == lay for p in (_points(it, []) if isinstance(it, list) else [])]
83
+ box = f" bbox({n(min(p[0] for p in pts))},{n(min(p[1] for p in pts))},{n(max(p[0] for p in pts))},{n(max(p[1] for p in pts))})" if pts else ""
84
+ h = hashlib.sha256("\n".join(cs).encode()).hexdigest()[:10]
85
+ out.append(f"graphics {kind} {lay}: {len(cs)}{box} #{h}")
86
+ return out
87
+
88
+
89
+ MANY_PADS = 16 # more pads than this (BGAs, big QFPs): pin 1 in full, the rest grouped by everything but position
90
+
91
+
92
+ def _pads(pads):
93
+ """pads: [(number, x, y, rest, full line)] -> lines. Few pads: every line. Many: pad 1 / A1 in full, then one line
94
+ per kind (same rest: shape, size, drill, layers ...) with count, bounding box of the centres and a hash of the lines."""
95
+ if len(pads) <= MANY_PADS:
96
+ return [p[4] for p in pads]
97
+ out = [p[4] for p in pads if p[0] in ("1", "A1")][:1]
98
+ kinds = {}
99
+ for p in pads:
100
+ kinds.setdefault(p[3], []).append(p)
101
+ for rest, ps in sorted(kinds.items()):
102
+ h = hashlib.sha256("\n".join(p[4] for p in ps).encode()).hexdigest()[:10]
103
+ out.append(f"pads x{len(ps)} {rest} centres bbox({n(min(p[1] for p in ps))},{n(min(p[2] for p in ps))},"
104
+ f"{n(max(p[1] for p in ps))},{n(max(p[2] for p in ps))}) #{h}")
105
+ return out
106
+
107
+
108
+ def kicad_mod(text):
109
+ from .. import sexpr
110
+ t = sexpr.parse(text)
111
+ lines, gfx, pads = [f"footprint {_atom(t[1])}"], {}, []
112
+ for c in t[2:]:
113
+ if not isinstance(c, list) or not c or c[0] in DROP_HEADS:
114
+ continue
115
+ if c[0] == "pad":
116
+ at = sexpr.first(c, "at") or ["at", "0", "0"]
117
+ rest = canon([x for x in c if not (isinstance(x, list) and x and x[0] == "at")][2:])
118
+ pads.append((str(c[1]), float(at[1]), float(at[2]), rest, canon(c)))
119
+ elif c[0] in GRAPHICS:
120
+ gfx.setdefault(c[0], []).append(c)
121
+ elif c[0] in ("property", "fp_text"):
122
+ at = sexpr.first(c, "at")
123
+ lines.append(f"{c[0]} " + " ".join(_atom(x) for x in c[1:] if not isinstance(x, list)) +
124
+ f" layer={_layer(c)}" + (f" at={canon(at)}" if at else "") +
125
+ (" hidden" if any(x == "hide" or (isinstance(x, list) and x[:2] == ["hide", "yes"]) for x in c) else ""))
126
+ else:
127
+ lines.append(canon(c))
128
+ lines += _pads(pads)
129
+ for k in sorted(gfx):
130
+ lines += _summary(k, gfx[k], _layer)
131
+ return lines
132
+
133
+
134
+ def kicad_sym(text):
135
+ """-> {symbol name: lines}."""
136
+ from .. import sexpr
137
+ t = sexpr.parse(text)
138
+ out = {}
139
+ for s in sexpr.find(t, "symbol"):
140
+ lines, gfx, pins = [], {}, []
141
+
142
+ def walk(node, unit):
143
+ for c in node[2:] if node is not s else node[2:]:
144
+ if not isinstance(c, list) or not c:
145
+ continue
146
+ if c[0] == "symbol":
147
+ walk(c, str(c[1]))
148
+ elif c[0] == "pin":
149
+ nm, no, at, ln = sexpr.first(c, "name") or ["", ""], sexpr.first(c, "number") or ["", ""], \
150
+ sexpr.first(c, "at"), sexpr.first(c, "length") or ["", "?"]
151
+ hid = any(x == "hide" or (isinstance(x, list) and x[:2] == ["hide", "yes"]) for x in c)
152
+ pins.append(f"pin {_atom(no[1])} {_atom(nm[1])} {c[1]} {c[2]} unit={unit} at={canon(at)} length={_atom(ln[1])}"
153
+ + (" hidden" if hid else ""))
154
+ elif c[0] in GRAPHICS or c[0] == "text":
155
+ gfx.setdefault(c[0], []).append([*c, ["layer", f"unit{unit}"]])
156
+ elif c[0] == "property":
157
+ at = sexpr.first(c, "at")
158
+ hid = any(x == "hide" or (isinstance(x, list) and x[:2] == ["hide", "yes"]) for x in sexpr.first(c, "effects") or []) \
159
+ or any(isinstance(x, list) and x[:2] == ["hide", "yes"] for x in c)
160
+ lines.append(f"property {_atom(c[1])}={_atom(c[2])}" + (f" at={canon(at)}" if at else "") + (" hidden" if hid else ""))
161
+ elif node is s:
162
+ lines.append(canon(c))
163
+ walk(s, "0")
164
+ for k in sorted(gfx):
165
+ lines += _summary(k, gfx[k], _layer)
166
+ out[str(s[1])] = lines + pins
167
+ return out
168
+
169
+
170
+ def _mm(u):
171
+ from .. import altium_lib as AL
172
+ return n(AL.to_mm(u))
173
+
174
+
175
+ def _params(d):
176
+ return " ".join(f"{k}={v}" for k, v in sorted(d.items()) if not VOLATILE.search(k))
177
+
178
+
179
+ def pcblib(path):
180
+ """-> {footprint name: lines} from altium_lib's reader (records, not bytes)."""
181
+ from .. import altium_lib as AL
182
+ out = {}
183
+ for fp in AL.read_pcblib(path):
184
+ lines, gfx, pads = [f"params {_params(fp['params'])}"], {}, []
185
+ for r in fp["records"]:
186
+ t = r["type"]
187
+ if t == "pad":
188
+ num = r["designator"] or '""'
189
+ rest = (f"layer={r['layer']} size=({_mm(r['w'])},{_mm(r['h'])}) hole={_mm(r['hole'])} shape={r['shape']} "
190
+ f"rot={n(r['rot'])} corner={r['corner']} plated={r['plated']} paste={r['paste_mode']}/{_mm(r['paste'] or 0)} "
191
+ f"mask={r['mask_mode']}/{_mm(r['mask'] or 0)} hole_shape={r['hole_shape']} slot={_mm(r['slot'])} "
192
+ f"hole_rot={n(r['hole_rot'])}")
193
+ pads.append((r["designator"], AL.to_mm(r["x"]), AL.to_mm(r["y"]), rest, f"pad {num} at=({_mm(r['x'])},{_mm(r['y'])}) {rest}"))
194
+ elif t == "text":
195
+ lines.append(f"text {json.dumps(r['text'])} layer={r['layer']} at=({_mm(r['x'])},{_mm(r['y'])}) h={_mm(r['h'])} "
196
+ f"w={_mm(r['w'])} rot={n(r['rot'])} mirror={r['mirror']}")
197
+ elif t == "body":
198
+ p = "|".join(x for x in r["params"].split("|") if not VOLATILE.search(x.split("=")[0]))
199
+ lines.append(f"body layer={r['layer']} {p} pts=" + ";".join(f"{_mm(x)},{_mm(y)}" for x, y in r["pts"]))
200
+ else:
201
+ if t in ("track", "fill"):
202
+ pts, txt = [(r["x1"], r["y1"]), (r["x2"], r["y2"])], f"{t} {r['x1']} {r['y1']} {r['x2']} {r['y2']} {r.get('w')}"
203
+ elif t == "arc":
204
+ pts = [(r["cx"] - r["r"], r["cy"] - r["r"]), (r["cx"] + r["r"], r["cy"] + r["r"])]
205
+ txt = f"arc {r['cx']} {r['cy']} {r['r']} {n(r['a0'])} {n(r['a1'])} {r['w']}"
206
+ elif t == "region":
207
+ pts, txt = list(r["pts"]), f"region {r['params']} " + ";".join(f"{n(x)},{n(y)}" for x, y in r["pts"])
208
+ else:
209
+ pts, txt = [], f"{t} {r['raw'][0].hex()}"
210
+ gfx.setdefault((t, r["layer"]), []).append((pts, txt))
211
+ lines += _pads(pads)
212
+ for (t, lay), items in sorted(gfx.items(), key=lambda kv: (kv[0][0], str(kv[0][1]))):
213
+ pts = [p for ps, _ in items for p in ps]
214
+ box = f" bbox({_mm(min(p[0] for p in pts))},{_mm(min(p[1] for p in pts))},{_mm(max(p[0] for p in pts))},{_mm(max(p[1] for p in pts))})" if pts else ""
215
+ lines.append(f"graphics {t} {lay}: {len(items)}{box} #{hashlib.sha256(chr(10).join(x for _, x in items).encode()).hexdigest()[:10]}")
216
+ for prm, data in fp.get("models") or []:
217
+ lines.append(f"model {_params(prm)} bytes={len(data)} sha256={hashlib.sha256(data).hexdigest()[:16]}")
218
+ out[fp["name"]] = lines
219
+ return out
220
+
221
+
222
+ def schlib(path):
223
+ """-> {symbol name: lines}: component fields, every pin, graphics as count + hash."""
224
+ from .. import altium_lib as AL
225
+ _, syms = AL.read_schlib(path)
226
+ out = {}
227
+ for s in syms:
228
+ lines, gfx = [], {}
229
+ for r in s["records"]:
230
+ rec = r.get("RECORD")
231
+ if rec == "2" and "num" in r:
232
+ lines.append(f"pin {r['num']} {json.dumps(r['name'])} etype={r['etype']} orient={r['orient']} at=({r['x']},{r['y']}) "
233
+ f"length={r['length']} part={r['part']} mode={r['mode']} symbols={list(r['symbols'])} cong={r['conglomerate']}")
234
+ elif rec in ("1", "34", "41", "45", "44", "46", "48"):
235
+ lines.append(f"record{rec} " + _params({k: v for k, v in r.items() if k not in ("RECORD", "INDEXINSHEET")}))
236
+ else:
237
+ gfx.setdefault(rec, []).append(_params({k: v for k, v in r.items() if k not in ("INDEXINSHEET",)}))
238
+ for rec, items in sorted(gfx.items(), key=lambda kv: str(kv[0])):
239
+ lines.append(f"graphics record{rec}: {len(items)} #{hashlib.sha256(chr(10).join(items).encode()).hexdigest()[:10]}")
240
+ out[s["name"]] = lines
241
+ return out
242
+
243
+
244
+ def table_rows(path):
245
+ """A CSV (DbLib table, parts export) or SQLite file -> sorted rows as lines."""
246
+ path = Path(path)
247
+ if path.suffix == ".sqlite":
248
+ con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
249
+ try:
250
+ out = []
251
+ for (t,) in con.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"):
252
+ cur = con.execute(f'SELECT * FROM "{t}"')
253
+ cols = [d[0] for d in cur.description]
254
+ out.append(f"table {t}: " + ",".join(cols))
255
+ out += sorted(f"row {t}: " + json.dumps(list(r), ensure_ascii=False) for r in cur)
256
+ return out
257
+ finally:
258
+ con.close()
259
+ rows = list(csv.reader(io.StringIO(path.read_text(encoding="utf-8-sig"))))
260
+ return ["header " + json.dumps(rows[0], ensure_ascii=False)] + sorted("row " + json.dumps(r, ensure_ascii=False) for r in rows[1:]) if rows else []
261
+
262
+
263
+ def fingerprint_file(path, rel):
264
+ """One output file -> {key: lines} (keys carry the relative path)."""
265
+ p = Path(path)
266
+ if p.suffix == ".kicad_mod":
267
+ return {rel: kicad_mod(p.read_text(encoding="utf-8"))}
268
+ if p.suffix == ".kicad_sym":
269
+ return {f"{rel}:{k}": v for k, v in kicad_sym(p.read_text(encoding="utf-8")).items()}
270
+ if p.suffix == ".PcbLib":
271
+ return {f"{rel}:{k}": v for k, v in pcblib(p).items()}
272
+ if p.suffix == ".SchLib":
273
+ return {f"{rel}:{k}": v for k, v in schlib(p).items()}
274
+ if p.suffix in (".csv", ".sqlite"):
275
+ return {rel: table_rows(p)}
276
+ raise ValueError(f"no fingerprint for {p.suffix}")
277
+
278
+
279
+ def fingerprint_tree(root, patterns):
280
+ root, out = Path(root), {}
281
+ for pat in patterns:
282
+ for f in sorted(root.glob(pat)):
283
+ out.update(fingerprint_file(f, f.relative_to(root).as_posix()))
284
+ return dict(sorted(out.items()))
285
+
286
+
287
+ # ---------------------------------------------------------------- the groups
288
+ def _quiet():
289
+ return lambda *a, **k: None
290
+
291
+
292
+ def build_testpack(tmp):
293
+ from .. import testpack
294
+ out = Path(tmp) / "testpack"
295
+ testpack.build(out, log=_quiet())
296
+ return fingerprint_tree(out, ["library/models/KiCad/*.kicad_mod", "library/models/PcbLib/*.PcbLib",
297
+ "library/models/SchLib/*.SchLib", "kicad/*.kicad_sym", "kicad/*.sqlite", "altium/*.csv"])
298
+
299
+
300
+ def build_demo(tmp):
301
+ from .. import firstrun
302
+ from ..app import Workspace
303
+ root = Path(tmp) / "demo"
304
+ firstrun.build_demo(Workspace(root, offline=True), "both", log=_quiet())
305
+ return fingerprint_tree(root, ["librarian/*/models/KiCad/*.kicad_mod", "librarian/*/models/PcbLib/*.PcbLib",
306
+ "librarian/*/models/SchLib/*.SchLib", "kicad/*/*.kicad_sym", "kicad/*/*.sqlite"])
307
+
308
+
309
+ def spec_names():
310
+ from .. import footprint_bench as FB, stdparts
311
+ own = sorted(p.stem for p in FB.SPECS.glob("*.json")) + sorted(p.stem for p in (FB.SPECS / "advanced").glob("*.json"))
312
+ std = sorted(stdparts.packages())[::SPEC_STRIDE]
313
+ return [("spec", x) for x in own] + [("standard", x) for x in std if x not in own]
314
+
315
+
316
+ def build_specs(tmp):
317
+ from .. import altium_lib as AL, footprint_bench as FB, footprint_gen as F, kicad_import as K, rules as RU, stdparts
318
+ out, d = {}, Path(tmp) / "specs"
319
+ d.mkdir(parents=True, exist_ok=True)
320
+ layers = RU.layers(RU.load({}))
321
+ for kind, name in spec_names():
322
+ spec = json.loads(FB.spec_path(name).read_text(encoding="utf-8")) if kind == "spec" else stdparts.spec(name)
323
+ try:
324
+ text, _ = F.build(spec)
325
+ except Exception as e: # a spec the generator rejects is part of the snapshot too
326
+ out[f"{kind}/{name}"] = [f"build failed: {type(e).__name__}: {e}"]
327
+ continue
328
+ out[f"{kind}/{name}.kicad_mod"] = kicad_mod(text)
329
+ fp, t = K.convert_footprint(text, name, kind, [])
330
+ try:
331
+ K.add_body(fp, t, None, None, False, [], gen_step=False, spec=spec)
332
+ except ValueError: # no height known: no body (as the librarian writes it)
333
+ pass
334
+ pcb = d / f"{AL.file_stem(name)}.PcbLib"
335
+ AL.write_pcblib(pcb, [fp], layers)
336
+ out.update({f"{kind}/{name}.PcbLib:{k}": v for k, v in pcblib(pcb).items()})
337
+ return dict(sorted(out.items()))
338
+
339
+
340
+ BUILDERS = {"testpack": build_testpack, "demo": build_demo, "specs": build_specs}
341
+
342
+
343
+ def build(group):
344
+ env = {k: v for k, v in os.environ.items() if not k.endswith("_API_KEY")}
345
+ from unittest import mock
346
+ with tempfile.TemporaryDirectory(prefix="ecadlib-golden-") as tmp, mock.patch.dict(os.environ, env, clear=True):
347
+ cwd = os.getcwd()
348
+ os.chdir(tmp) # no ecadlib.toml of the caller's folder can change the outputs
349
+ try:
350
+ return BUILDERS[group](tmp)
351
+ finally:
352
+ os.chdir(cwd)
353
+
354
+
355
+ # ---------------------------------------------------------------- files, diff
356
+ def dumps(fps):
357
+ out = []
358
+ for k, lines in fps.items():
359
+ out.append(f"### {k}")
360
+ out += lines
361
+ return "\n".join(out) + "\n"
362
+
363
+
364
+ def loads(text):
365
+ fps, cur = {}, None
366
+ for line in text.splitlines():
367
+ if line.startswith("### "):
368
+ cur = fps.setdefault(line[4:], [])
369
+ elif cur is not None:
370
+ cur.append(line)
371
+ return fps
372
+
373
+
374
+ def path(group, d=None):
375
+ return Path(d or HERE) / f"{group}.txt"
376
+
377
+
378
+ def diff(old, new, limit=80):
379
+ """-> readable text: items added / removed, and a unified diff of each changed item (first `limit` lines)."""
380
+ out = []
381
+ for k in sorted(set(old) - set(new)):
382
+ out.append(f"- removed: {k}")
383
+ for k in sorted(set(new) - set(old)):
384
+ out.append(f"+ added: {k}")
385
+ for k in sorted(set(old) & set(new)):
386
+ if old[k] != new[k]:
387
+ out.append(f"~ changed: {k}")
388
+ out += [" " + l for l in list(difflib.unified_diff(old[k], new[k], "golden", "now", n=0, lineterm=""))[2:]]
389
+ if len(out) > limit:
390
+ out = out[:limit] + [f"... {len(out) - limit} more lines"]
391
+ return "\n".join(out)
392
+
393
+
394
+ def check(groups=GROUPS, d=None):
395
+ """-> {group: diff text ('' when the same)}."""
396
+ res = {}
397
+ for g in groups:
398
+ f = path(g, d)
399
+ old = loads(f.read_text(encoding="utf-8")) if f.exists() else {}
400
+ new = build(g)
401
+ res[g] = diff(old, new) if old != new else ""
402
+ if not f.exists():
403
+ res[g] = f"no snapshot {f}: run `ecadlib eval golden --update`\n" + res[g]
404
+ return res
405
+
406
+
407
+ def update(groups=GROUPS, d=None):
408
+ out = {}
409
+ for g in groups:
410
+ f = path(g, d)
411
+ f.parent.mkdir(parents=True, exist_ok=True)
412
+ fps = build(g)
413
+ f.write_text(dumps(fps), encoding="utf-8")
414
+ out[g] = (f, len(fps))
415
+ return out
416
+
417
+
418
+ def main(argv=None):
419
+ ap = argparse.ArgumentParser(prog="ecadlib eval golden", description=__doc__.split("\n")[0])
420
+ ap.add_argument("--update", action="store_true", help="rewrite the snapshots from this build")
421
+ ap.add_argument("--group", help=f"comma list of {', '.join(GROUPS)} (default all)")
422
+ ap.add_argument("--dir", help="snapshot folder (default ecadlib/eval/golden)")
423
+ a = ap.parse_args(argv)
424
+ groups = [g.strip() for g in a.group.split(",")] if a.group else list(GROUPS)
425
+ bad = [g for g in groups if g not in GROUPS]
426
+ if bad:
427
+ ap.error(f"unknown group {bad[0]!r}; groups: {', '.join(GROUPS)}")
428
+ if a.update:
429
+ for g, (f, k) in update(groups, a.dir).items():
430
+ print(f"{g}: {k} items -> {f}")
431
+ print("review the diff (git diff ecadlib/eval/golden) and commit it with the change that caused it")
432
+ return 0
433
+ res, rc = check(groups, a.dir), 0
434
+ for g, txt in res.items():
435
+ if txt:
436
+ rc = 1
437
+ print(f"golden {g}: CHANGED\n{txt}")
438
+ else:
439
+ print(f"golden {g}: same")
440
+ if rc:
441
+ print("\nIf the change is deliberate: `ecadlib eval golden --update`, review `git diff ecadlib/eval/golden`, commit.")
442
+ return rc
443
+
444
+
445
+ if __name__ == "__main__":
446
+ sys.exit(main())
Binary file