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
@@ -0,0 +1,437 @@
1
+ """Check every part of an altium365 plan before it goes to Altium 365; hold back what fails.
2
+
3
+ python3 -m ecadlib.release_check out/librarian/plan.json [--allow PART ...] [--unallow PART ...] [-o REPORT_STEM]
4
+
5
+ Rules, per plan item:
6
+ files the .SchLib and .PcbLib exist and read back with altium_lib's reader (cannot be overridden)
7
+ pins every symbol pin has a pad with the same number and every pad a pin; unnumbered, NPTH and
8
+ mounting pads (MP, MH, SH, MTG, ...) are allowed without a pin
9
+ footprint footprint_check and the rule book (footprint_rules, docs/footprint-rulebook.md) on the item's .kicad_mod:
10
+ every footprint_check error fails, and rule-book errors in FR.RELEASE_HOLDS (name, layers, type, annular
11
+ ring, thermal vias, silkscreen text size / over pads, also on the PcbLib); a reversed diode / LED / polarized-capacitor pin map fails for every part. Parts taken from the official KiCad
12
+ library are held only for overlapping pads, a missing courtyard and a missing pin 1 (their other
13
+ findings are reported as warnings). Without a .kicad_mod the same three are checked on the PcbLib.
14
+ Overlapping pads whose symbol pins share a name (USB-C A1/B12 = GND) are stacked on purpose: a warning.
15
+ height a 3D height is present and inside the plausible range for the package family (HEIGHTS)
16
+ fields Manufacturer, Manufacturer Part Number, a datasheet link and Description are filled in
17
+ parity the .kicad_mod and the .PcbLib / .SchLib read back the same (ecadlib.parity: pads, drills, paste, courtyard,
18
+ 3D, pin -> pad); `[release] parity = "warn"` reports instead of holding, "off" skips it
19
+ verify the item carries `verify` notes (e.g. a datasheet-reader footprint): a person has to look first
20
+ Failing items get status `held` and `hold_reasons`; others `ready`. `--allow PART` records an override in the
21
+ item (`allow.failures` = the failures it covers); an allowed item is `ready` while its failures stay within
22
+ that list. altium365 push releases only `ready` items. Items `ecadlib a365 pull` brought in from the workspace
23
+ (status `existing`) are already released there and are not checked. Exit code 0 when nothing is held, 2 otherwise."""
24
+ import argparse, csv, datetime, re, sys
25
+ from pathlib import Path
26
+
27
+ from . import altium_lib as AL, footprint_check as FC, footprint_rules as FR
28
+ from . import sheetsafe as SS
29
+ from .fsutil import write_text_atomic
30
+
31
+ # plausible overall height (mm) per package family: first regex (on footprint name) that matches wins
32
+ HEIGHTS = [
33
+ (r"^LED_(0201|0402|0603|0805|1206)", 0.2, 1.4), (r"(^|[_-])01005(?!\d)", 0.1, 0.3), (r"(^|[_-])0201(?!\d)", 0.15, 0.45), (r"(^|[_-])0402(?!\d)", 0.2, 0.8),
34
+ (r"(^|[_-])0603(?!\d)", 0.3, 1.1), (r"(^|[_-])0805(?!\d)", 0.35, 1.6), (r"(^|[_-])1206(?!\d)", 0.4, 2.0),
35
+ (r"(^|[_-])(1210|1812|2010|2512)(?!\d)", 0.4, 3.0), (r"^CP_EIA", 0.8, 3.5),
36
+ (r"^SOT-223", 1.4, 1.95), (r"^SOT-23", 0.8, 1.45), (r"^SOT-89", 1.3, 1.7), (r"^SOT-?(323|363|353|563|666)", 0.45, 1.1),
37
+ (r"^(D_)?SOD-?(123|323|523|923)", 0.4, 1.4), (r"^(D_)?(SMA|SMB|SMC)\b", 1.9, 2.7),
38
+ (r"^(TSSOP|MSOP|VSSOP|HTSSOP|TSOP)", 0.75, 1.25), (r"^SSOP", 1.2, 2.05), (r"^(SOIC|SOP|SO-)", 1.2, 1.85),
39
+ (r"(QFN|DFN|[^S]SON|^SON|WSON|USON)", 0.3, 1.05), (r"^LQFP", 1.35, 1.7), (r"^TQFP", 1.0, 1.25), (r"QFP", 1.0, 4.1),
40
+ (r"BGA", 0.4, 2.6), (r"^Crystal_SMD_(1612|2016|2520|3225|5032)", 0.35, 1.4), (r"^Crystal_SMD_HC49", 3.0, 5.0),
41
+ (r"^USB_C_Receptacle", 2.0, 3.6), (r"^JST_SH", 1.8, 3.5), (r"^PinHeader_.*P1\.27mm", 2.0, 9.0),
42
+ (r"^PinHeader_.*P2\.54mm", 2.5, 12.0), (r"^(ESP32|RF_Module)", 1.5, 4.0), (r"^LED_", 0.2, 6.0),
43
+ ]
44
+ GENERIC_HEIGHT = (0.05, 30.0)
45
+ MECH_PAD = re.compile(r"(MP|MH|SH|MTG|MOUNT|MECH|NPTH|H)\d*", re.I)
46
+ REQUIRED = ["Manufacturer", "Manufacturer Part Number"]
47
+ UNFIXABLE = ("files",) # rules --allow cannot override
48
+
49
+
50
+ def height_range(fp_name):
51
+ for rx, lo, hi in HEIGHTS:
52
+ if re.search(rx, fp_name or "", re.I):
53
+ return lo, hi, rx
54
+ return (*GENERIC_HEIGHT, None)
55
+
56
+
57
+ def _package_height(item, fp_name):
58
+ """(height, label) from kicad_import.package_default_height for a standard chip size / small package, or None."""
59
+ from .kicad_import import package_default_height
60
+ cat = str(item.get("folder") or "").split("/")[-1]
61
+ kind = {"Resistors": "R", "Capacitors": "C"}.get(cat)
62
+ names = [item["footprint"].get("kicad"), item["footprint"].get("ref"), fp_name, item.get("description"), item.get("name")]
63
+ return package_default_height(names, kind)
64
+
65
+
66
+ def _mil(s):
67
+ m = re.match(r"\s*(-?[\d.]+)\s*mil", str(s or ""))
68
+ return float(m.group(1)) * 0.0254 if m else None
69
+
70
+
71
+ def _path(base, p):
72
+ return (Path(base) / p) if p and not Path(p).is_absolute() else (Path(p) if p else None)
73
+
74
+
75
+ def _kicad_mod(item, base, fp_file):
76
+ p = _path(base, item["footprint"].get("kicad_mod"))
77
+ if p and p.exists():
78
+ return p
79
+ if fp_file: # <models>/KiCad/<name>.kicad_mod next to <models>/PcbLib
80
+ q = fp_file.parent.parent / "KiCad" / f"{AL.file_stem(item['footprint']['ref'])}.kicad_mod"
81
+ return q if q.exists() else None
82
+ return None
83
+
84
+
85
+ def _pad_rects(pads):
86
+ out = []
87
+ for r in pads:
88
+ w, h = AL.to_mm(r["w"]), AL.to_mm(r["h"])
89
+ if round(r["rot"]) % 180 == 90:
90
+ w, h = h, w
91
+ out.append((r["designator"], AL.to_mm(r["x"]), AL.to_mm(r["y"]), w, h, r["rot"] % 90 != 0))
92
+ return out
93
+
94
+
95
+ OVERLAP = re.compile(r"pads (\S+) and (\S+) overlap")
96
+
97
+
98
+ def _same_net(msg, pin_names):
99
+ """True when an overlap message names two pads whose symbol pins share a name (e.g. USB-C A1/B12 = GND):
100
+ such pads are stacked on purpose and are connected anyway."""
101
+ m = OVERLAP.search(msg)
102
+ if not m:
103
+ return False
104
+ a, b = (pin_names.get(n, "").strip() for n in m.groups())
105
+ return bool(a) and a == b and a not in ("~", m.group(1), m.group(2))
106
+
107
+
108
+ def _pcblib_minimal(fp, pin_names=None, courtyard=AL.MECH15, pin1=None):
109
+ """Overlap, courtyard and pin 1 from the PcbLib records (used when there is no .kicad_mod)."""
110
+ fails, warns, pads = [], [], [r for r in fp["records"] if r["type"] == "pad"]
111
+ rects = _pad_rects([p for p in pads if p["hole"] == 0 or p["plated"]])
112
+ for i, a in enumerate(rects):
113
+ for b in rects[i + 1:]:
114
+ if a[0] and a[0] == b[0] or a[5] or b[5]:
115
+ continue
116
+ if abs(a[1] - b[1]) < (a[3] + b[3]) / 2 - 0.005 and abs(a[2] - b[2]) < (a[4] + b[4]) / 2 - 0.005:
117
+ msg = f"pads {a[0]} and {b[0]} overlap"
118
+ (warns if _same_net(msg, pin_names or {}) else fails).append(msg)
119
+ if not any(r["layer"] == courtyard for r in fp["records"] if r["type"] in ("track", "arc", "region")):
120
+ fails.append(f"no courtyard (Mechanical {courtyard - 56})")
121
+ if not _has_pin1({p["designator"] for p in pads}, pin1):
122
+ fails.append("no pin 1 pad")
123
+ return fails, warns
124
+
125
+
126
+ def _has_pin1(nums, pin1=None):
127
+ return bool(set(pin1 or ("1", "A1")) & set(nums))
128
+
129
+
130
+ def check_item(item, base=".", rules=None):
131
+ """-> (failures [(rule, message)], warnings [str], info {height, ...}). rules (ecadlib.rules): required fields,
132
+ accepted pin-1 names and the courtyard layer; None = ecadlib's own."""
133
+ from . import rules as RU
134
+ rules = rules or RU.DEFAULTS
135
+ pin1 = rules["footprint"]["pin1"]
136
+ fails, warns, info = [], [], {}
137
+ add = lambda rule, msg: fails.append((rule, msg))
138
+ sym_f, fp_f = _path(base, item["symbol"].get("file")), _path(base, item["footprint"].get("file"))
139
+ syms = fps = None
140
+ pin_names = {}
141
+ for kind, f, reader in (("symbol", sym_f, AL.read_schlib), ("footprint", fp_f, AL.read_pcblib)):
142
+ if not f:
143
+ add("files", f"no {kind} model (.{'SchLib' if kind == 'symbol' else 'PcbLib'})")
144
+ elif not f.exists():
145
+ add("files", f"{kind} file missing: {f}")
146
+ else:
147
+ try:
148
+ r = reader(f)
149
+ r = r[1] if kind == "symbol" else r
150
+ if not r:
151
+ raise ValueError("library is empty")
152
+ if kind == "symbol":
153
+ syms = r
154
+ else:
155
+ fps = r
156
+ except Exception as e: # unreadable: altium_lib's reader is the gate
157
+ add("files", f"{kind} file does not read back ({type(e).__name__}: {e})")
158
+ # ---- pins vs pads
159
+ if syms and fps:
160
+ pins = {r["num"] for s in syms for r in s["records"] if r.get("RECORD") == "2"}
161
+ pin_names = {r["num"]: r.get("name", "") for s in syms for r in s["records"] if r.get("RECORD") == "2"}
162
+ pads = [r for r in fps[0]["records"] if r["type"] == "pad"]
163
+ if not pins:
164
+ add("pins", "symbol has no pins")
165
+ if not pads:
166
+ add("pins", "footprint has no pads")
167
+ grid = FC.mount_rx([p["designator"] for p in pads]) is FC.MOUNT_GRID # BGA: H1, H2 .. are balls, not holes
168
+ mech = {p["designator"] for p in pads if not p["designator"] or (p["hole"] and not p["plated"])
169
+ or MECH_PAD.fullmatch(p["designator"]) and not (grid and re.fullmatch(r"H\d+", p["designator"]))}
170
+ numbered = {p["designator"] for p in pads} - mech
171
+ if pins - {p["designator"] for p in pads}:
172
+ add("pins", f"symbol pins without a pad: {FC._ranges(pins - {p['designator'] for p in pads})}")
173
+ if numbered - pins:
174
+ add("pins", f"pads without a symbol pin: {FC._ranges(numbered - pins)}")
175
+ if mech - {""}:
176
+ warns.append(f"mechanical pads without a pin (allowed): {FC._ranges(mech - {''})}")
177
+ # ---- footprint rules
178
+ src = str((item.get("source") or {}).get("footprint") or "")
179
+ kicad_lib = src.startswith("kicad:")
180
+ kmod = _kicad_mod(item, base, fp_f) if fp_f else None
181
+ if kmod:
182
+ info["kicad_mod"] = str(kmod)
183
+ text = kmod.read_text(encoding="utf-8")
184
+ try:
185
+ issues, nums = FC.check(text), {p["num"] for p in FC._pad_rects(FC.sexpr.parse(text))}
186
+ except Exception as e: # checker crash: fall back to the PcbLib minimum below
187
+ issues, nums, kmod = [], set(), None
188
+ warns.append(f"footprint_check failed on {info.pop('kicad_mod')} ({type(e).__name__}: {e})")
189
+ for lvl, rule, msg in issues:
190
+ if rule == "pin1" and msg == "no pad numbered 1" and _has_pin1(nums, pin1):
191
+ continue # A1 counts as pin 1 (connectors, BGAs)
192
+ if rule == "copper" and "overlap" in msg and _same_net(msg, pin_names):
193
+ warns.append(f"{lvl} {rule}: {msg} (same pin name '{pin_names[OVERLAP.search(msg).group(1)]}': stacked on purpose)")
194
+ continue
195
+ hard = lvl == "error" and (not kicad_lib or (rule == "copper" and "overlap" in msg) or
196
+ (rule == "courtyard" and msg.startswith("no courtyard")) or (rule == "pin1" and "no pad" in msg))
197
+ (add("footprint", f"{rule}: {msg}") if hard else warns.append(f"{lvl} {rule}: {msg}"))
198
+ if kmod: # the rule book (footprint_rules): naming, layers, type, pin map ...
199
+ try:
200
+ book = FR.check(text, filename=kmod.name, symbol_pins=pin_names if syms else None, geometry=False)
201
+ except Exception as e: # a rule-book crash must not hide the other rules
202
+ book = [("warning", "rulebook", f"footprint_rules failed ({type(e).__name__}: {e})")]
203
+ for lvl, rule, msg in book:
204
+ if rule == "connect":
205
+ continue # the pins rule above already compares the Altium symbol and footprint
206
+ if fps and msg.startswith("in the Altium export"):
207
+ continue # the PcbLib itself is checked below
208
+ hard = lvl == "error" and (rule == "pinmap" or (not kicad_lib and rule in FR.RELEASE_HOLDS))
209
+ (add("footprint", f"{rule}: {msg}") if hard else warns.append(f"{lvl} {rule}: {msg}"))
210
+ if not kmod and fps:
211
+ mf, mw = _pcblib_minimal(fps[0], pin_names, RU.layer_id(rules["layers"]["courtyard"]), pin1)
212
+ for m in mf:
213
+ add("footprint", m)
214
+ warns += [f"{m} (same pin name: stacked on purpose)" for m in mw]
215
+ warns.append("no .kicad_mod: only overlap, courtyard and pin 1 checked (on the PcbLib)")
216
+ if fps: # silkscreen text as Altium shows it: size, over pads, over the body
217
+ for lvl, msg in FR.pcblib_text_issues(fps[0]):
218
+ hard = lvl == "error" and not kicad_lib
219
+ (add("footprint", f"silktext: {msg}") if hard else warns.append(f"{lvl} silktext: {msg}"))
220
+ # ---- KiCad and Altium read back the same (ecadlib.parity)
221
+ mode = (rules.get("release") or {}).get("parity", "hold")
222
+ if mode != "off" and kmod and fps:
223
+ from . import parity as PY
224
+ try:
225
+ diffs = PY.release_diffs(text, fps[0], syms, rules)
226
+ except Exception as e: # a parity crash must not hide the other rules
227
+ diffs = [{"level": "warn", "msg": f"parity check failed ({type(e).__name__}: {e})"}]
228
+ for d in diffs:
229
+ (add("parity", d["msg"]) if d["level"] == "error" and mode == "hold" else warns.append(f"parity: {d['msg']}"))
230
+ info["parity"] = "mismatch" if any(d["level"] == "error" for d in diffs) else "match"
231
+ # ---- the footprint written for Fusion / EAGLE and EasyEDA Pro reads back the same (eagle_export, easyeda_pro_export)
232
+ for tool, module, label in (("eagle", "eagle_export", "EAGLE / Fusion"), ("easyeda", "easyeda_pro_export", "EasyEDA Pro")):
233
+ tmode = (rules.get("release") or {}).get(tool, "warn")
234
+ if tmode == "off" or not kmod:
235
+ continue
236
+ import importlib
237
+ try:
238
+ diffs = importlib.import_module(f"ecadlib.{module}").check_footprint(text, kmod.stem)
239
+ except Exception as e: # a writer crash must not hide the other rules
240
+ diffs = [{"level": "error", "msg": f"{label} export failed ({type(e).__name__}: {e})"}]
241
+ for d in diffs:
242
+ if d["level"] == "error":
243
+ (add(tool, f"{label}: {d['msg']}") if tmode == "hold" else warns.append(f"{tool}: {d['msg']}"))
244
+ info[tool] = "mismatch" if any(d["level"] == "error" for d in diffs) else "match"
245
+ # ---- 3D height
246
+ if fps:
247
+ bodies = [AL.parse_params(r["params"]) for r in fps[0]["records"] if r["type"] == "body"]
248
+ hs = [h for h in (_mil(b.get("OVERALLHEIGHT")) for b in bodies) if h]
249
+ h = max(hs) if hs else _mil(fps[0]["params"].get("HEIGHT"))
250
+ if not bodies and h:
251
+ warns.append("height only as the footprint HEIGHT parameter; no 3D body")
252
+ lo, hi, fam = height_range(item["footprint"].get("kicad") or item["footprint"].get("ref") or fps[0]["name"])
253
+ info["height"] = h
254
+ default = any("package default" in n for n in (item.get("librarian") or {}).get("notes", []))
255
+ pd = None if h else _package_height(item, fps[0]["name"])
256
+ if not h and pd:
257
+ warns.append(f"HEIGHT from package default {pd[0]} mm ({pd[1]}), verify: the PcbLib has no 3D height (no API key "
258
+ "for the datasheet, no KiCad model)")
259
+ info["height"] = pd[0]
260
+ elif not h:
261
+ add("height", "no 3D height (no 3D body and no HEIGHT)")
262
+ elif default:
263
+ warns.append(f"3D height {h:.2f} mm is the package default, verify against the datasheet")
264
+ if not lo - 0.01 <= h <= hi + 0.01:
265
+ warns.append(f"3D height {h:.2f} mm outside {lo}-{hi} mm for {fam or 'any package'}")
266
+ elif not lo - 0.01 <= h <= hi + 0.01:
267
+ add("height", f"3D height {h:.2f} mm outside {lo}-{hi} mm for {fam or 'any package'}")
268
+ elif not fam:
269
+ warns.append(f"no height range for package '{item['footprint'].get('ref')}'; only checked {lo}-{hi} mm")
270
+ # ---- fields
271
+ par = {p["name"]: p["value"] for p in item.get("parameters", [])}
272
+ for k in rules["parameters"]["required"]: # default: REQUIRED, then the datasheet link and Description
273
+ if k == "Datasheet":
274
+ if not any(str(par.get(x, "")).strip() for x in ("ComponentLink2URL", "Datasheet", RU.canonical_param("Datasheet", rules))):
275
+ add("fields", "no datasheet link")
276
+ elif k == "Description":
277
+ if not str(item.get("description") or "").strip():
278
+ add("fields", "Description is empty")
279
+ elif str((item.get("field_sources") or {}).get("Description", "")).startswith("written by ecadlib"):
280
+ warns.append(f"Description \"{item['description']}\" was written by ecadlib from the category and "
281
+ "package (no source had one): check it")
282
+ elif not str(par.get(RU.canonical_param(k, rules), par.get(k, ""))).strip():
283
+ add("fields", f"{k} is empty")
284
+ for v in item.get("verify") or []:
285
+ add("verify", v)
286
+ return fails, warns, info
287
+
288
+
289
+ def apply(items, base=".", allow=(), unallow=(), who="--allow", rules=None):
290
+ """Run check_item on every item, set status ready / held, record --allow overrides. Returns report rows.
291
+ rules: a loaded rules dict, a path, or None to use the ecadlib.toml found from `base` (defaults when none)."""
292
+ from . import rules as RU
293
+ rules = RU.resolve(rules, base)
294
+ rows, today = [], datetime.date.today().isoformat()
295
+ for it in items:
296
+ if it.get("a365") and it.get("status") in ("existing", "deleted_in_workspace"):
297
+ # pulled from Altium 365 (ecadlib a365 pull): already released there, nothing to release or hold back
298
+ it["hold_reasons"], it["release_check"] = [], {"result": it["status"], "failures": [], "warnings": []}
299
+ rows.append({"name": it["name"], "status": it["status"], "result": it["status"], "symbol_source": "a365",
300
+ "footprint_source": "a365", "height_mm": "", "failures": "", "warnings": "", "allowed": ""})
301
+ continue
302
+ fails, warns, info = check_item(it, base, rules)
303
+ msgs = [f"{r}: {m}" for r, m in fails]
304
+ if it["name"] in unallow:
305
+ it.pop("allow", None)
306
+ if it["name"] in allow:
307
+ it["allow"] = {"by": who, "date": today, "failures": [m for m in msgs if not m.startswith(UNFIXABLE)]}
308
+ for k in ("hold_reasons", "release_check"): # the key order a librarian re-run writes: no plan.json churn
309
+ if k in it:
310
+ it[k] = it.pop(k)
311
+ covered = set((it.get("allow") or {}).get("failures", []))
312
+ hard = [m for m in msgs if m.startswith(UNFIXABLE)]
313
+ open_ = [m for m in msgs if m not in covered]
314
+ if not msgs:
315
+ it["status"], result = "ready", "pass"
316
+ elif it.get("allow") and not open_:
317
+ it["status"], result = "ready", "allowed"
318
+ else:
319
+ it["status"], result = "held", "held"
320
+ it["hold_reasons"] = open_ if result == "held" else []
321
+ if result == "held" and it.get("allow"):
322
+ it["hold_reasons"] = [m + (" (cannot be overridden)" if m in hard else " (not covered by --allow)") for m in open_]
323
+ it["release_check"] = {"result": result, "failures": msgs, "warnings": warns, **{k: v for k, v in info.items() if k in ("height", "parity")}}
324
+ rows.append({"name": it["name"], "status": it["status"], "result": result,
325
+ "symbol_source": (it.get("source") or {}).get("symbol") or "", "footprint_source": (it.get("source") or {}).get("footprint") or "",
326
+ "height_mm": f"{info['height']:.2f}" if info.get("height") else "", "failures": "; ".join(msgs),
327
+ "warnings": "; ".join(warns), "allowed": "yes" if it.get("allow") else ""})
328
+ from .approvals import post_check # sign-off policy (approvals.json next to the plan) and dedupe status
329
+ post_check(items, base, rows)
330
+ return rows
331
+
332
+
333
+ def _split(failures):
334
+ """A report row's "rule: msg; rule: msg" back into reasons (messages may contain "; " themselves)."""
335
+ return [x for x in re.split(r"; (?=(?:%s): )" % "|".join(("files", "pins", "footprint", "height", "fields", "parity", "verify")),
336
+ failures or "") if x]
337
+
338
+
339
+ def write_report(rows, stem, plan=""):
340
+ stem = Path(stem)
341
+ stem.parent.mkdir(parents=True, exist_ok=True)
342
+ with open(stem.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
343
+ w = SS.DictWriter(f, fieldnames=list(rows[0]) if rows else ["name", "status"])
344
+ w.writeheader(); w.writerows(rows)
345
+ from . import messages as M
346
+ held = [r for r in rows if r["status"] == "held"]
347
+ allowed = sum(r["result"] == "allowed" for r in rows)
348
+ where = Path(str(plan)).parent.name if plan else ""
349
+ md = [f"# Release check{': ' + where if where else ''}", "",
350
+ f"**{M.plural(len(rows), 'part')}: {M.counts(r['status'] for r in rows)}.**"
351
+ + (f" {allowed} of the ready ones {'was' if allowed == 1 else 'were'} released anyway." if allowed else ""), "",
352
+ ("Held parts stay out of the library until they are fixed or released anyway." if held
353
+ else "Nothing is held."), ""]
354
+ if held:
355
+ md += [f"## Held ({len(held)})", ""]
356
+ for r in held:
357
+ md += [f"### {r['name']}", ""]
358
+ for e in M.explain_all(_split(r["failures"])):
359
+ md.append(f"- {e['text']}" + (f" {e['note']}" if e["note"] else "") + f" \n **Next:** {e['fix']}")
360
+ md.append("")
361
+ rest = [r for r in rows if r["status"] != "held"]
362
+ if rest:
363
+ md += [f"## Not held ({len(rest)})", "", "| Part | Status | Symbol from | Footprint from | Height |", "|---|---|---|---|---|"]
364
+ md += [f"| {r['name']} | {M.status_word('allowed' if r['result'] == 'allowed' else r['status'])} | {M.source_word(r['symbol_source'])} | "
365
+ f"{M.source_word(r['footprint_source'])} | {r['height_mm'] + ' mm' if r['height_mm'] else '-'} |" for r in rest]
366
+ md.append("")
367
+ warned = [r for r in rows if r["warnings"]]
368
+ if warned:
369
+ md += ["## Warnings (not held)", ""] + [f"- **{r['name']}**: {M.clean(r['warnings'])}" for r in warned]
370
+ stem.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
371
+ return stem
372
+
373
+
374
+ def print_result(rows, plan, stem, t=None):
375
+ """The terminal view: one row per part (status, name, the reason in plain words), then the summary."""
376
+ from . import messages as M, term as TM
377
+ t = t or TM.Term()
378
+ table = []
379
+ for r in rows:
380
+ st = {"pass": "ready", "allowed": "ready"}.get(r["result"], r["status"])
381
+ word = "released anyway" if r["result"] == "allowed" else None
382
+ reasons = [x for x in (r["failures"] or "").split("; ") if x] if r["status"] == "held" else []
383
+ why = M.explain_all(reasons)
384
+ first = why[0]["text"] + (f" (+{len(why) - 1} more)" if len(why) > 1 else "") if why else ""
385
+ table.append([t.status(st, word), r["name"], first])
386
+ if t.verbose: # -v: every reason, each with its next step
387
+ for k, e in enumerate(why):
388
+ table += ([["", "", e["text"]]] if k else []) + [["", "", f"fix: {e['fix']}"]]
389
+ t.table(table)
390
+ held = [r["name"] for r in rows if r["status"] == "held"]
391
+ nxt = [(f"ecadlib review {plan}", "go through the held parts one by one")] if held else []
392
+ if any(r["status"] == "awaiting_approval" for r in rows):
393
+ nxt.append((f"ecadlib approve {plan} status", "who still has to sign off"))
394
+ if not held:
395
+ nxt.append((f"ecadlib kicad-export {plan} -o kicad --name mylib", "write the KiCad library"))
396
+ t.summary(M.counts(r["status"] for r in rows) + (f" (held: {', '.join(held[:5])}{' ...' if len(held) > 5 else ''})" if held else ""),
397
+ written=[(str(stem.with_suffix(".md")), "release check report"), (str(plan), "library plan, statuses updated")],
398
+ next=nxt)
399
+
400
+
401
+ def main(argv=None):
402
+ import json
403
+ ap = argparse.ArgumentParser(description="Check every plan item before it goes to Altium 365; hold back what fails")
404
+ ap.add_argument("plan")
405
+ ap.add_argument("--allow", action="append", default=[], help="part name to release despite its current failures (repeatable)")
406
+ ap.add_argument("--unallow", action="append", default=[], help="remove an earlier --allow")
407
+ ap.add_argument("-o", "--out", help="report stem (.md and .csv); default <plan dir>/release_check")
408
+ ap.add_argument("--user", help="who gives the --allow (default: git config user.name/email); see approvals")
409
+ ap.add_argument("--rules", help="library rules file (default: ecadlib.toml found from the plan's folder or here)")
410
+ ap.add_argument("--json", action="store_true", help="print the result as JSON (plan, report, counts, rows)")
411
+ a = ap.parse_args(argv)
412
+ plan = Path(a.plan)
413
+ from .approvals import cli_plan_lock
414
+ with cli_plan_lock(plan): # the library agent's librarian may be rewriting this plan right now
415
+ items = json.loads(plan.read_text(encoding="utf-8"))
416
+ unknown = set(a.allow + a.unallow) - {i["name"] for i in items}
417
+ if unknown:
418
+ sys.exit(f"not in the plan: {', '.join(sorted(unknown))}")
419
+ rows = apply(items, plan.parent, a.allow, a.unallow, rules=a.rules)
420
+ if a.allow: # record the person, so approvals can refuse self-approval
421
+ from .approvals import identity, post_check, stamp_allow
422
+ stamp_allow(items, a.allow, identity(a.user, cwd=plan.parent))
423
+ post_check(items, plan.parent, rows)
424
+ write_text_atomic(plan, json.dumps(items, indent=2))
425
+ stem = write_report(rows, a.out or plan.parent / "release_check", plan)
426
+ held = sum(r["status"] == "held" for r in rows)
427
+ if a.json: # stable shape: README "Machine-readable output (--json)"
428
+ st = ("ready", "held", "awaiting_approval", "duplicate")
429
+ print(json.dumps({"plan": str(plan), "report": str(stem.with_suffix(".md")),
430
+ "counts": {s: sum(r["status"] == s for r in rows) for s in st}, "rows": rows}, indent=2, default=str))
431
+ return 2 if held else 0
432
+ print_result(rows, plan, stem)
433
+ return 2 if held else 0
434
+
435
+
436
+ if __name__ == "__main__":
437
+ sys.exit(main())