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/upstream.py ADDED
@@ -0,0 +1,573 @@
1
+ """Upstream change alerts: KiCad library parts we imported, and Altium 365 components that drift from the plan.
2
+
3
+ python3 -m ecadlib.upstream check-kicad out/altium_models_kicad/manifest.json [-o out/upstream_kicad] [--update]
4
+ NEXAR_TOKEN=... python3 -m ecadlib.upstream check-a365 out/altium365/plan.json --workspace URL \
5
+ [--folder Components] [--state out/a365_state.json] [-o out/upstream_a365]
6
+
7
+ Provenance: kicad_import.import_part calls record() after writing a part, which adds an entry to
8
+ <out>/manifest.json per output file: KiCad ref, source URLs (footprint, STEP model, symbol and its `extends`
9
+ parents), sha256 and git blob id of each source, the last GitLab commit touching it, a small summary of the
10
+ source (pads and courtyard, pins and key properties, STEP extent), the output file and its sha256, and the
11
+ converter version. check-kicad asks the GitLab API (HEAD .../repository/files, no download) for each source's
12
+ blob id, fetches only the changed ones and diffs their summaries; a 404 is traced through the commit that removed
13
+ the path to tell `deleted` from `renamed`. --update reconverts parts whose sources changed (not deleted/renamed).
14
+ check-a365 lists the workspace library and compares it with a plan.json: missing components, parameter, comment,
15
+ description, folder, symbol and footprint names, and with --state the component revision and the symbol/footprint
16
+ GUIDs seen last time (Nexar exposes no symbol/footprint revision, so a GUID change is the closest signal).
17
+ Reports: <out>.md and <out>.csv. Exit codes: 0 no changes, 1 changes found, 2 error. GITLAB_TOKEN is optional."""
18
+ import argparse, csv, hashlib, json, os, re, sys
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from urllib.parse import quote
22
+
23
+ from . import __version__, kicad_import as K, sexpr
24
+ from . import sheetsafe as SS
25
+ from .httpcache import CachedHTTP, Response
26
+
27
+ RAW = re.compile(r"https://gitlab\.com/kicad/libraries/([^/]+)/-/raw/([^/]+)/(.+)$")
28
+ API = "https://gitlab.com/api/v4/projects/kicad%2Flibraries%2F{repo}/repository"
29
+ RANK = {"unchanged": 0, "changed": 1, "renamed": 2, "deleted": 3, "error": 4}
30
+
31
+
32
+ def converter_version():
33
+ return f"ecadlib {__version__} kicad_import {hashlib.sha256(Path(K.__file__).read_bytes()).hexdigest()[:12]}"
34
+
35
+
36
+ def digests(text):
37
+ b = text.encode("utf-8")
38
+ return hashlib.sha256(b).hexdigest(), hashlib.sha1(b"blob %d\0" % len(b) + b).hexdigest() # blob id = git's
39
+
40
+
41
+ # ---------------------------------------------------------------- source summaries (what a diff talks about)
42
+ def fp_summary(text):
43
+ t = sexpr.parse(text)
44
+ pads = []
45
+ for p in sexpr.find(t, "pad"):
46
+ at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
47
+ pads.append([p[1], *(round(float(v), 4) for v in (at[1], at[2], size[1], size[2])), p[3],
48
+ round(float(at[3]), 2) if len(at) > 3 else 0.0])
49
+ cy = sexpr.courtyard_bbox(t)
50
+ return {"pads": pads, "courtyard": [round(v, 4) for v in cy] if cy else None}
51
+
52
+
53
+ def _find_symbol(text, name):
54
+ return next((s for s in sexpr.find(sexpr.parse(text), "symbol") if s[1] == name), None)
55
+
56
+
57
+ def sym_summary(text, name):
58
+ s = _find_symbol(text, name)
59
+ if not s:
60
+ return {"missing": True}
61
+ pins = {}
62
+ for sub in sexpr.find(s, "symbol"):
63
+ if sub[1].rsplit("_", 1)[-1] not in ("0", "1"): # De Morgan body style
64
+ continue
65
+ for p in sexpr.find(sub, "pin"):
66
+ pins[(sexpr.first(p, "number") or ["", ""])[1]] = [(sexpr.first(p, "name") or ["", ""])[1], p[1]]
67
+ props = {p[1]: p[2] for p in sexpr.find(s, "property") if p[1] in ("Value", "Footprint", "Datasheet", "Description")}
68
+ return {"pins": pins, "props": props, "extends": (sexpr.first(s, "extends") or [None, None])[1]}
69
+
70
+
71
+ def step_summary(text):
72
+ ext = K.step_extent(text, {"scale": [1, 1, 1], "rotate": [0, 0, 0], "offset": [0, 0, 0]})
73
+ return {"extent": [round(v, 3) for v in ext] if ext else None}
74
+
75
+
76
+ def summarize(src, text):
77
+ return {"footprint": lambda: fp_summary(text), "step": lambda: step_summary(text),
78
+ "symbol": lambda: sym_summary(text, src["symbol"])}[src["role"]]()
79
+
80
+
81
+ def _few(xs, n=6):
82
+ xs = list(xs)
83
+ return ", ".join(xs[:n]) + (f" +{len(xs) - n} more" if len(xs) > n else "")
84
+
85
+
86
+ def diff_fp(a, b):
87
+ def keyed(pads):
88
+ out, seen = {}, {}
89
+ for p in pads:
90
+ k = seen[p[0]] = seen.get(p[0], -1) + 1
91
+ out[(p[0] or "(npth)") + (f"#{k + 1}" if k else "")] = p
92
+ return out
93
+ ka, kb = keyed(a["pads"]), keyed(b["pads"])
94
+ out = [f"pads {len(ka)} -> {len(kb)}"] if len(ka) != len(kb) else []
95
+ if set(kb) - set(ka):
96
+ out.append("added " + _few(sorted(set(kb) - set(ka))))
97
+ if set(ka) - set(kb):
98
+ out.append("removed " + _few(sorted(set(ka) - set(kb))))
99
+ common = [k for k in ka if k in kb]
100
+ moved = [f"{k} ({ka[k][1]:g},{ka[k][2]:g})->({kb[k][1]:g},{kb[k][2]:g})" for k in common if ka[k][1:3] != kb[k][1:3]]
101
+ sized = [f"{k} {ka[k][3]:g}x{ka[k][4]:g}->{kb[k][3]:g}x{kb[k][4]:g}" for k in common if ka[k][3:5] != kb[k][3:5]]
102
+ shaped = [f"{k} {ka[k][5]}/{ka[k][6]:g}->{kb[k][5]}/{kb[k][6]:g}" for k in common if ka[k][5:] != kb[k][5:]]
103
+ out += [f"{lbl} {_few(x, 4)}" for lbl, x in (("moved", moved), ("resized", sized), ("shape/rotation", shaped)) if x]
104
+ if a["courtyard"] != b["courtyard"]:
105
+ out.append(f"courtyard {a['courtyard']} -> {b['courtyard']}")
106
+ return out
107
+
108
+
109
+ def diff_sym(a, b):
110
+ if b.get("missing"):
111
+ return ["symbol no longer in the file"]
112
+ pa, pb = a.get("pins", {}), b.get("pins", {})
113
+ out = []
114
+ if set(pb) - set(pa):
115
+ out.append("pins added " + _few(f"{n} {pb[n][0]}" for n in sorted(set(pb) - set(pa))))
116
+ if set(pa) - set(pb):
117
+ out.append("pins removed " + _few(f"{n} {pa[n][0]}" for n in sorted(set(pa) - set(pb))))
118
+ common = sorted(set(pa) & set(pb))
119
+ ren = [f"{n} {pa[n][0]}->{pb[n][0]}" for n in common if pa[n][0] != pb[n][0]]
120
+ typ = [f"{n} {pa[n][1]}->{pb[n][1]}" for n in common if pa[n][1] != pb[n][1]]
121
+ out += [f"{lbl} {_few(x, 4)}" for lbl, x in (("pins renamed", ren), ("pin types", typ)) if x]
122
+ props = sorted(k for k in set(a.get("props", {})) | set(b.get("props", {})) if a["props"].get(k) != b["props"].get(k))
123
+ if props:
124
+ out.append("properties changed: " + ", ".join(props))
125
+ if a.get("extends") != b.get("extends"):
126
+ out.append(f"extends {a.get('extends')} -> {b.get('extends')}")
127
+ return out
128
+
129
+
130
+ def diff_summary(role, a, b):
131
+ if not a or not b:
132
+ return []
133
+ if role == "footprint":
134
+ return diff_fp(a, b)
135
+ if role == "symbol":
136
+ return diff_sym(a, b)
137
+ return [f"STEP extent {a['extent']} -> {b['extent']}"] if a.get("extent") != b.get("extent") else []
138
+
139
+
140
+ # ---------------------------------------------------------------- provenance (called by kicad_import)
141
+ def _remote(repo, path):
142
+ return K.GITLAB.format(repo=repo, path=path)
143
+
144
+
145
+ def fp_sources(ref, http):
146
+ """[(role, location, text, extra)] the converter read for a footprint: the .kicad_mod and its STEP model."""
147
+ if Path(ref).suffix == ".kicad_mod":
148
+ loc, text = str(ref), Path(ref).read_text(encoding="utf-8")
149
+ else:
150
+ lib, name = ref.split(":", 1)
151
+ path = f"{lib}.pretty/{name}.kicad_mod"
152
+ loc, text = _remote("kicad-footprints", path), K.fetch(http, "kicad-footprints", path)
153
+ out = [("footprint", loc, text, {})]
154
+ mi = K.model_info(sexpr.parse(text))
155
+ mp = K.model_url_path(mi["path"]) if mi else None
156
+ if mp and http is not None: # add_body only fetches the model when it has an http client
157
+ st = K.fetch(http, "kicad-packages3D", mp)
158
+ if st is not None:
159
+ out.append(("step", _remote("kicad-packages3D", mp), st, {}))
160
+ return out
161
+
162
+
163
+ def sym_sources(ref, http):
164
+ """The symbol file(s) of a symbol and its `extends` parents, resolved as kicad_import.load_symbol does."""
165
+ m = re.match(r"(.+\.kicad_sym)(?::(.+))?$", ref)
166
+ if m:
167
+ path = Path(m.group(1))
168
+ def get(name):
169
+ for f in (path, path.with_name(f"{name}.kicad_sym")):
170
+ if f.exists() and _find_symbol(f.read_text(encoding="utf-8"), name):
171
+ return str(f), f.read_text(encoding="utf-8")
172
+ raise FileNotFoundError(f"symbol {name} not in {path}")
173
+ name = m.group(2) or [s[1] for s in sexpr.find(sexpr.parse(path.read_text(encoding="utf-8")), "symbol")][0]
174
+ else:
175
+ lib, name = ref.split(":", 1)
176
+ def get(name):
177
+ for p in (f"{lib}.kicad_symdir/{name}.kicad_sym", f"{lib}.kicad_sym"):
178
+ text = K.fetch(http, "kicad-symbols", p)
179
+ if text and _find_symbol(text, name):
180
+ return _remote("kicad-symbols", p), text
181
+ raise FileNotFoundError(f"KiCad symbol {lib}:{name} not found")
182
+ out = []
183
+ while name and len(out) < 8:
184
+ loc, text = get(name)
185
+ out.append(("symbol", loc, text, {"symbol": name}))
186
+ name = (sexpr.first(_find_symbol(text, name), "extends") or [None, None])[1]
187
+ return out
188
+
189
+
190
+ def last_commit(http, repo, path):
191
+ if http is None:
192
+ return None
193
+ url = API.format(repo=repo) + f"/commits?path={quote(path)}&ref_name=master&per_page=1"
194
+ try:
195
+ r = http.get(url)
196
+ c = r.json()[0] if r.ok else None
197
+ return {"id": c["id"], "date": c["created_at"], "title": c["title"]} if c else None
198
+ except Exception:
199
+ return None
200
+
201
+
202
+ def source_record(role, loc, text, extra, http):
203
+ sha, blob = digests(text)
204
+ src = {"role": role, **extra, "sha256": sha, "blob_id": blob}
205
+ m = RAW.match(loc)
206
+ if m:
207
+ src.update(url=loc, repo=m.group(1), ref=m.group(2), path=m.group(3), last_commit=last_commit(http, m.group(1), m.group(3)))
208
+ else:
209
+ src["file"] = loc
210
+ src["summary"] = summarize(src, text)
211
+ return src
212
+
213
+
214
+ def load_manifest(path):
215
+ p = Path(path)
216
+ return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {"version": 1, "entries": {}}
217
+
218
+
219
+ def save_manifest(path, man):
220
+ p = Path(path)
221
+ p.parent.mkdir(parents=True, exist_ok=True)
222
+ tmp = p.with_suffix(".tmp")
223
+ tmp.write_text(json.dumps(man, indent=1), encoding="utf-8")
224
+ os.replace(tmp, p)
225
+
226
+
227
+ def record(out, spec, rep, http, step=False, gen_step=True, layers=None):
228
+ """Add/replace the manifest entries for a part kicad_import just wrote. Never raises: a failure is a note.
229
+ layers: the rules' PcbLib layers the part was written with, so a reconversion keeps them."""
230
+ try:
231
+ mpath, now = Path(out) / "manifest.json", datetime.now(timezone.utc).isoformat(timespec="seconds")
232
+ man = load_manifest(mpath)
233
+ for kind, key, ref, srcs in (("footprint", "pcblib", spec.get("footprint"), fp_sources),
234
+ ("symbol", "schlib", spec.get("symbol"), sym_sources)):
235
+ if not rep.get(key) or not ref:
236
+ continue
237
+ f = Path(rep[key])
238
+ man["entries"][f"{kind}:{f.stem}"] = {
239
+ "kind": kind, "kicad_ref": ref, "mpn": spec.get("mpn", ""),
240
+ "output": os.path.relpath(f, Path(out)).replace(os.sep, "/"), "output_sha256": hashlib.sha256(f.read_bytes()).hexdigest(),
241
+ "converter": converter_version(), "converted_at": now, "step": bool(step), "gen_step": bool(gen_step),
242
+ "layers": layers,
243
+ "spec": {k: v for k, v in spec.items() if v is not None},
244
+ "sources": [source_record(*s, http) for s in srcs(ref, http)]}
245
+ save_manifest(mpath, man)
246
+ except Exception as e:
247
+ rep.setdefault("notes", []).append(f"provenance not recorded: {type(e).__name__}: {e}")
248
+
249
+
250
+ # ---------------------------------------------------------------- GitLab
251
+ class GitLab:
252
+ """GitLab REST v4 for gitlab.com/kicad/libraries/*: HEAD for blob ids, raw content, last commit, rename tracing."""
253
+ def __init__(self, session=None, token=None, ref="master"):
254
+ if session is None:
255
+ import requests
256
+ session = requests.Session()
257
+ self.s, self.ref = session, ref
258
+ self.h = {"PRIVATE-TOKEN": token} if token else {}
259
+
260
+ def _req(self, method, url, params=None):
261
+ r = self.s.request(method, url, params=params, headers=self.h, timeout=60)
262
+ if r.status_code == 404:
263
+ return None
264
+ if not 200 <= r.status_code < 300:
265
+ raise IOError(f"GitLab {method} {url}: HTTP {r.status_code}")
266
+ return r
267
+
268
+ def _file(self, repo, path):
269
+ return API.format(repo=repo) + "/files/" + quote(path, safe="")
270
+
271
+ def head(self, repo, path):
272
+ r = self._req("HEAD", self._file(repo, path), {"ref": self.ref})
273
+ return {"blob_id": r.headers.get("x-gitlab-blob-id"), "last_commit_id": r.headers.get("x-gitlab-last-commit-id")} if r else None
274
+
275
+ def raw(self, repo, path):
276
+ r = self._req("GET", self._file(repo, path) + "/raw", {"ref": self.ref})
277
+ return r.content.decode("utf-8") if r else None
278
+
279
+ def commit(self, repo, path):
280
+ r = self._req("GET", API.format(repo=repo) + "/commits", {"path": path, "ref_name": self.ref, "per_page": 1})
281
+ c = r.json()[0] if r and r.json() else None
282
+ return {"id": c["id"], "date": c["created_at"], "title": c["title"]} if c else None
283
+
284
+ def moved(self, repo, path):
285
+ """A path that 404s: ('renamed', new_path, commit) or ('deleted', None, commit) from the commit that last touched it."""
286
+ c = self.commit(repo, path)
287
+ if not c:
288
+ return "deleted", None, None
289
+ for page in range(1, 51):
290
+ r = self._req("GET", API.format(repo=repo) + f"/commits/{c['id']}/diff", {"per_page": 100, "page": page})
291
+ files = r.json() if r else []
292
+ for d in files:
293
+ if d.get("old_path") == path:
294
+ return ("renamed", d["new_path"], c) if d.get("renamed_file") else ("deleted", None, c)
295
+ if len(files) < 100:
296
+ break
297
+ return "deleted", None, c
298
+
299
+
300
+ # ---------------------------------------------------------------- check-kicad
301
+ def check_source(src, gl, memo):
302
+ """-> dict(status, changes, new_path, commit, text)"""
303
+ key = (src.get("repo"), src.get("path") or src.get("file"))
304
+ if key not in memo:
305
+ res = {"status": "unchanged", "changes": [], "new_path": None, "commit": None, "text": None}
306
+ try:
307
+ if src.get("repo"):
308
+ info = gl.head(src["repo"], src["path"])
309
+ if info is None:
310
+ res["status"], res["new_path"], res["commit"] = gl.moved(src["repo"], src["path"])
311
+ res["changes"] = [f"{res['status']} upstream" + (f" in \"{res['commit']['title']}\"" if res["commit"] else "")]
312
+ elif info["blob_id"] != src["blob_id"]:
313
+ res.update(status="changed", text=gl.raw(src["repo"], src["path"]), commit=gl.commit(src["repo"], src["path"]))
314
+ else:
315
+ f = Path(src["file"])
316
+ if not f.exists():
317
+ res["status"] = "deleted"
318
+ elif digests(f.read_text(encoding="utf-8"))[0] != src["sha256"]:
319
+ res.update(status="changed", text=f.read_text(encoding="utf-8"))
320
+ except Exception as e:
321
+ res.update(status="error", changes=[f"{type(e).__name__}: {e}"])
322
+ memo[key] = res
323
+ res = dict(memo[key])
324
+ if res["status"] == "changed":
325
+ try:
326
+ res["changes"] = diff_summary(src["role"], src.get("summary"), summarize(src, res["text"])) or \
327
+ ["content changed (pads, courtyard, pins and properties the same)"]
328
+ except Exception as e:
329
+ res["changes"] = [f"changed; summary failed: {type(e).__name__}: {e}"]
330
+ return res
331
+
332
+
333
+ def check_kicad(man, gl):
334
+ """-> (rows, per-entry worst status)"""
335
+ rows, worst, memo = [], {}, {}
336
+ for key, e in man["entries"].items():
337
+ for src in e["sources"]:
338
+ r = check_source(src, gl, memo)
339
+ worst[key] = max(worst.get(key, "unchanged"), r["status"], key=RANK.get)
340
+ rows.append({"entry": key, "kind": e["kind"], "kicad_ref": e["kicad_ref"], "role": src["role"],
341
+ "source": src.get("path") or src.get("file"), "status": r["status"],
342
+ "summary": "; ".join(r["changes"]) + (f"; now {r['new_path']}" if r["new_path"] else ""), "spec": e["spec"],
343
+ "upstream_commit": (r["commit"] or {}).get("id", "")[:12], "upstream_date": (r["commit"] or {}).get("date", ""),
344
+ "_text": r["text"], "_src": src})
345
+ return rows, worst
346
+
347
+
348
+ class Pinned:
349
+ """http wrapper for kicad_import: URLs in `pins` answer with that text (what check-kicad just fetched)."""
350
+ def __init__(self, http, pins):
351
+ self.http, self.pins = http, pins
352
+
353
+ def get(self, url, **kw):
354
+ return Response(200, self.pins[url]) if url in self.pins else self.http.get(url, **kw)
355
+
356
+
357
+ def _rules_layers(out):
358
+ """Layers of the rules found from `out` (as `kicad_import -o out` uses), for entries recorded without them."""
359
+ from .rules import layers, load
360
+ try:
361
+ return layers(load(None, start=out))
362
+ except Exception:
363
+ return None
364
+
365
+
366
+ def reconvert(entries, out, http):
367
+ """Re-run kicad_import for the specs behind `entries` (one conversion per spec); record() refreshes the manifest."""
368
+ done = {}
369
+ for k in ("PcbLib", "SchLib"):
370
+ (Path(out) / k).mkdir(parents=True, exist_ok=True)
371
+ for e in entries:
372
+ spec = e["spec"]
373
+ k = json.dumps(spec, sort_keys=True)
374
+ if k in done:
375
+ continue
376
+ if spec.get("footprint"):
377
+ rep = K.import_part(spec, out, http, e.get("step", False), gen_step=e.get("gen_step", False),
378
+ layers=e["layers"] if "layers" in e else _rules_layers(out))
379
+ else: # symbol-only conversion (kicad_import symbol ...)
380
+ rep = {"notes": [], "ok": False}
381
+ try:
382
+ chain, _ = K.load_symbol(spec["symbol"], http)
383
+ sym = K.convert_symbol(chain, spec.get("symbol_as"), spec.get("symbol_footprint"),
384
+ [tuple(kv.split("=", 1)) for kv in spec.get("params", [])], None, rep["notes"])
385
+ rep["schlib"] = str(K.AL.write_schlib(Path(out) / "SchLib" / f"{K.AL.file_stem(sym['name'])}.SchLib", [sym]))
386
+ rep["ok"] = True
387
+ record(out, spec, rep, http)
388
+ except Exception as ex:
389
+ rep["error"] = f"{type(ex).__name__}: {ex}"
390
+ done[k] = rep
391
+ return done
392
+
393
+
394
+ # ---------------------------------------------------------------- check-a365
395
+ Q_COMPONENTS = """query ($ws: String!, $after: String) {
396
+ desLibrary(workspaceUrl: $ws) {
397
+ components(first: 100, after: $after) {
398
+ pageInfo { hasNextPage endCursor }
399
+ nodes { id name comment description isManaged folder { path }
400
+ revision { id details { lifeCycleState { name } createdAt } }
401
+ details { parameters { name value } symbols { name guid } footprints { name guid } } }
402
+ }
403
+ }
404
+ }"""
405
+
406
+
407
+ def workspace_components(client, ws):
408
+ out, after = [], None
409
+ while True:
410
+ conn = client.gql(Q_COMPONENTS, {"ws": ws, "after": after})["desLibrary"]["components"] or {}
411
+ out += conn.get("nodes") or []
412
+ page = conn.get("pageInfo") or {}
413
+ if not page.get("hasNextPage"):
414
+ return out
415
+ after = page["endCursor"]
416
+
417
+
418
+ def _folder(p):
419
+ return "/".join(x for x in (p or "").replace("\\", "/").split("/") if x).lower()
420
+
421
+
422
+ def check_a365(items, nodes, folders=None, state=None, rules=None):
423
+ """plan items vs workspace nodes -> (rows, new state). state: {name: {revision, symbols, footprints}} from last run.
424
+ rules: the plan's parameters are compared under the rules' house names, as push sends them."""
425
+ from .altium365 import merged_params
426
+ from .rules import house_item
427
+ items = [house_item(i, rules) for i in items]
428
+ by = {}
429
+ for n in nodes:
430
+ by.setdefault(n["name"], n)
431
+ watch = {_folder(f) for f in (folders or [i["folder"] for i in items])}
432
+ rows, new_state = [], {}
433
+ row = lambda name, status, field="", plan="", ws="", detail="": rows.append(
434
+ {"name": name, "status": status, "field": field, "plan": plan, "workspace": ws, "detail": detail})
435
+ for it in items:
436
+ n = by.get(it["name"])
437
+ if not n:
438
+ row(it["name"], "missing", detail=f"plan status {it.get('status', '')}"); continue
439
+ d = n.get("details") or {}
440
+ cur = {p["name"]: p["value"] for p in d.get("parameters") or []}
441
+ if merged_params(n, it) is not None:
442
+ for p in it["parameters"]:
443
+ if cur.get(p["name"]) != p["value"]:
444
+ row(it["name"], "parameter", p["name"], p["value"], cur.get(p["name"], "(absent)"))
445
+ for k in it.get("cleared", []):
446
+ if k in cur:
447
+ row(it["name"], "parameter", k, "(cleared)", cur[k])
448
+ for f in ("comment", "description"):
449
+ if f in n and (it.get(f) or "") != (n[f] or ""):
450
+ row(it["name"], f, f, it.get(f) or "", n[f] or "")
451
+ if n.get("folder") and _folder(n["folder"].get("path")) != _folder(it["folder"]):
452
+ row(it["name"], "folder", "folder", it["folder"], n["folder"].get("path"))
453
+ for kind, key in (("symbol", "symbols"), ("footprint", "footprints")):
454
+ names = [x["name"] for x in d.get(key) or []]
455
+ ref = (it.get(kind) or {}).get("ref")
456
+ if ref and names and ref not in names:
457
+ row(it["name"], kind, kind, ref, ", ".join(names))
458
+ rev = n.get("revision") or {}
459
+ snap = {"revision": rev.get("id"), "lifecycle": ((rev.get("details") or {}).get("lifeCycleState") or {}).get("name"),
460
+ "symbols": sorted(x.get("guid") or "" for x in d.get("symbols") or []),
461
+ "footprints": sorted(x.get("guid") or "" for x in d.get("footprints") or [])}
462
+ new_state[it["name"]] = snap
463
+ old = (state or {}).get(it["name"])
464
+ if old:
465
+ for k in ("revision", "symbols", "footprints"):
466
+ if old.get(k) != snap[k]:
467
+ row(it["name"], "revision", k, str(old.get(k)), str(snap[k]), f"changed in the workspace since the last check"
468
+ + (f" (lifecycle {snap['lifecycle']})" if snap["lifecycle"] else ""))
469
+ planned = {i["name"] for i in items}
470
+ for n in nodes:
471
+ f = _folder((n.get("folder") or {}).get("path"))
472
+ if n["name"] not in planned and any(f == w or f.startswith(w + "/") for w in watch):
473
+ row(n["name"], "not_in_plan", "folder", "", (n.get("folder") or {}).get("path"),
474
+ "managed" if n.get("isManaged") else "unmanaged")
475
+ return rows, new_state
476
+
477
+
478
+ # ---------------------------------------------------------------- reports & CLI
479
+ def write_report(rows, out, title, cols, meta=()):
480
+ out = Path(out)
481
+ out.parent.mkdir(parents=True, exist_ok=True)
482
+ with open(out.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
483
+ w = SS.DictWriter(f, fieldnames=cols, extrasaction="ignore")
484
+ w.writeheader(); w.writerows(rows)
485
+ esc = lambda v: str(v if v is not None else "").replace("|", "\\|").replace("\n", " ")
486
+ md = [f"# {title}", "", *meta, ""]
487
+ if rows:
488
+ md += ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)] + ["| " + " | ".join(esc(r.get(c)) for c in cols) + " |" for r in rows]
489
+ else:
490
+ md.append("No changes.")
491
+ out.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
492
+
493
+
494
+ KICAD_COLS = ["entry", "kind", "kicad_ref", "role", "source", "status", "summary", "upstream_commit", "upstream_date", "update"]
495
+ A365_COLS = ["name", "status", "field", "plan", "workspace", "detail"]
496
+
497
+
498
+ def main(argv=None):
499
+ ap = argparse.ArgumentParser(description="Upstream change alerts for KiCad imports and Altium 365 workspaces")
500
+ sub = ap.add_subparsers(dest="cmd", required=True)
501
+ k = sub.add_parser("check-kicad", help="compare imported KiCad parts with gitlab.com/kicad/libraries")
502
+ k.add_argument("manifest", nargs="?", default="out/altium_models_kicad/manifest.json")
503
+ k.add_argument("--update", action="store_true", help="reconvert parts whose sources changed")
504
+ k.add_argument("--all", action="store_true", help="list unchanged sources in the report too")
505
+ k.add_argument("--cache", default="out/http_cache")
506
+ k.add_argument("--offline", action="store_true", help="--update reads unchanged sources from the HTTP cache only")
507
+ k.add_argument("-o", "--out", default="out/upstream_kicad", help="report path stem (.md and .csv)")
508
+ a3 = sub.add_parser("check-a365", help="compare a plan.json with an Altium 365 workspace (NEXAR_TOKEN)")
509
+ a3.add_argument("plan")
510
+ a3.add_argument("--workspace", required=True)
511
+ a3.add_argument("--folder", action="append", help="workspace folder to look for unplanned components (default: the plan's)")
512
+ a3.add_argument("--state", help="JSON snapshot of revisions/GUIDs; compared, then rewritten")
513
+ a3.add_argument("-o", "--out", default="out/upstream_a365", help="report path stem (.md and .csv)")
514
+ a3.add_argument("--rules", help="library rules file: house parameter names (default: ecadlib.toml found from the plan)")
515
+ a = ap.parse_args(argv)
516
+ now = datetime.now(timezone.utc).isoformat(timespec="seconds")
517
+
518
+ if a.cmd == "check-kicad":
519
+ if not Path(a.manifest).is_file():
520
+ print(f"check-kicad: no manifest at {a.manifest}: it lists the parts `python -m ecadlib.kicad_import batch ... -o DIR` "
521
+ "converted (DIR/manifest.json); give that file, e.g. `ecadlib upstream check-kicad DIR/manifest.json`",
522
+ file=sys.stderr)
523
+ return 2
524
+ try:
525
+ man = json.loads(Path(a.manifest).read_text(encoding="utf-8"))
526
+ rows, worst = check_kicad(man, GitLab(token=os.environ.get("GITLAB_TOKEN")))
527
+ except Exception as e:
528
+ print(f"check-kicad: {type(e).__name__}: {e}", file=sys.stderr); return 2
529
+ if a.update:
530
+ ch = [r for r in rows if r["status"] == "changed"]
531
+ todo = {r["entry"] for r in ch}
532
+ if todo:
533
+ pins = {r["_src"]["url"]: r["_text"] for r in ch if r["_src"].get("url") and r["_text"] is not None}
534
+ done = reconvert([man["entries"][e] for e in sorted(todo)], Path(a.manifest).parent,
535
+ Pinned(CachedHTTP(a.cache, ttl_s=0, offline=a.offline, timeout=60), pins))
536
+ for r in rows: # every entry of a reconverted spec (footprint and its symbol) was rewritten
537
+ rep = done.get(json.dumps(r["spec"], sort_keys=True))
538
+ if rep:
539
+ r["update"] = "reconverted" if rep.get("ok") else f"failed: {rep.get('error')}"
540
+ shown = rows if a.all else [r for r in rows if r["status"] != "unchanged"]
541
+ counts = {s: sum(v == s for v in worst.values()) for s in RANK}
542
+ write_report(shown, a.out, "KiCad upstream changes", KICAD_COLS,
543
+ [f"Manifest `{a.manifest}`, checked {now}.", "",
544
+ "Parts: " + ", ".join(f"{v} {s}" for s, v in counts.items() if v)])
545
+ for r in shown:
546
+ print(f" {r['status']:<9} {r['entry']:<32} {r['source']:<56} {r['summary']}" + (f" [{r['update']}]" if r.get("update") else ""))
547
+ print(" ".join(f"{v} {s}" for s, v in counts.items() if v) + f" -> {Path(a.out).with_suffix('.md')} and .csv")
548
+ return 2 if counts["error"] else 1 if any(counts[s] for s in ("changed", "renamed", "deleted")) else 0
549
+
550
+ token = os.environ.get("NEXAR_TOKEN")
551
+ if not token:
552
+ print("check-a365: set NEXAR_TOKEN (design.domain, user.access, openid)", file=sys.stderr); return 2
553
+ try:
554
+ from .altium365 import Nexar
555
+ items = json.loads(Path(a.plan).read_text(encoding="utf-8"))
556
+ state = json.loads(Path(a.state).read_text(encoding="utf-8")) if a.state and Path(a.state).exists() else None
557
+ from .rules import load
558
+ rules = load(a.rules, start=Path(a.plan).parent)
559
+ rows, new_state = check_a365(items, workspace_components(Nexar(token), a.workspace), a.folder, state, rules)
560
+ except Exception as e:
561
+ print(f"check-a365: {type(e).__name__}: {e}", file=sys.stderr); return 2
562
+ if a.state:
563
+ Path(a.state).parent.mkdir(parents=True, exist_ok=True)
564
+ Path(a.state).write_text(json.dumps(new_state, indent=1), encoding="utf-8")
565
+ write_report(rows, a.out, "Altium 365 drift", A365_COLS, [f"Plan `{a.plan}` vs `{a.workspace}`, checked {now}."])
566
+ for r in rows:
567
+ print(f" {r['status']:<11} {r['name']:<28} {r['field']:<24} plan={r['plan']!r} workspace={r['workspace']!r}")
568
+ print(f"{len(rows)} differences -> {Path(a.out).with_suffix('.md')} and .csv")
569
+ return 1 if rows else 0
570
+
571
+
572
+ if __name__ == "__main__":
573
+ sys.exit(main())