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/bom_triage.py ADDED
@@ -0,0 +1,519 @@
1
+ """BOM triage: what's already in the local KiCad library, what LCSC/EasyEDA can supply,
2
+ and what needs a footprint generated.
3
+
4
+ python -m ecadlib.bom_triage examples/sample_bom.csv --lib examples/kicad_lib --lib out/footprints \
5
+ -o out/triage [--offline] [--providers easyeda,jlcpcb_search,digikey,...]
6
+ """
7
+ import argparse, csv, difflib, json, re, sys
8
+ from pathlib import Path
9
+
10
+ import pandas as pd
11
+
12
+ from . import providers as P, sheetsafe as SS
13
+ from .httpcache import CachedHTTP
14
+ from .kicad_index import KiCadIndex, package_of, parse_value
15
+
16
+ FIELDS = { # canonical field -> header synonyms (normalised: lowercase, punctuation -> space)
17
+ "mpn": ["mpn", "manufacturer part number", "mfr part number", "mfr part", "mfg part number", "mfr pn",
18
+ "manufacturer part", "part number", "mfr no", "manufacturer pn", "pn", "mfr part no"],
19
+ "manufacturer": ["manufacturer", "mfr", "mfr name", "mfg", "maker", "brand", "vendor", "manufacturer name"],
20
+ "lcsc": ["lcsc", "lcsc part", "lcsc part number", "lcsc part no", "lcsc pn", "jlcpcb part", "jlc part", "lcsc id"],
21
+ "qty": ["qty", "quantity", "count", "qnty", "amount", "pcs"],
22
+ "designator": ["designator", "designators", "reference", "references", "ref", "refdes", "ref des", "parts"],
23
+ "value": ["value", "val", "comment"],
24
+ "footprint": ["footprint", "package", "case", "package case", "pcb footprint"],
25
+ "description": ["description", "desc", "notes"],
26
+ "libref": ["libref", "lib ref", "library ref", "library reference", "symbol"],
27
+ "dnp": ["dnp", "dnf", "do not populate", "do not place", "do not fit", "no stuff", "not fitted"],
28
+ "datasheet": ["datasheet", "data sheet", "datasheet url", "datasheet link"],
29
+ }
30
+ DELIMS = (",", ";", "\t", "|")
31
+
32
+
33
+ def _norm(h):
34
+ return re.sub(r"[^a-z0-9]+", " ", str(h).lower()).strip()
35
+
36
+
37
+ def _hnorm(h):
38
+ """A header normalised for matching: '#' and 'P/N' mean part number ('Mfr. #' is the MPN, not the maker)."""
39
+ return _norm(re.sub(r"(?i)\bp\s*/\s*n\b", " pn ", str(h)).replace("#", " no "))
40
+
41
+
42
+ def map_headers(headers, threshold=0.72):
43
+ """Fuzzy, case-insensitive header -> field mapping. Returns {field: header}."""
44
+ scores = []
45
+ for h in headers:
46
+ nh = _hnorm(h)
47
+ for f, syns in FIELDS.items():
48
+ best = 0.0
49
+ for s in syns:
50
+ if nh == s:
51
+ sc = 0.97 if f == "mpn" and s in ("part number", "pn") else 1.0 # DigiKey: 'Part Number' is
52
+ # its own code, 'Manufacturer Part Number' the MPN
53
+ elif re.search(rf"\b{re.escape(s)}\b", nh):
54
+ sc = 0.75 + 0.2 * len(s) / max(len(nh), 1) # containment, longer = better
55
+ else:
56
+ sc = difflib.SequenceMatcher(None, nh, s).ratio() * 0.95
57
+ best = max(best, sc)
58
+ if f != "lcsc" and re.search(r"\b(lcsc|jlc|jlcpcb)\b", nh):
59
+ best *= 0.5 # 'LCSC Part Number' is not the MPN
60
+ if f == "lcsc" and not re.search(r"\b(lcsc|jlc|jlcpcb)", nh):
61
+ best *= 0.5 # nor is a bare 'Part Number' an LCSC code
62
+ if f == "mpn" and re.search(r"\b(supplier|distributor|digi ?key|mouser|farnell|newark|arrow|tme|vendor)\b", nh):
63
+ best *= 0.5 # nor is a distributor's order code
64
+ if f == "manufacturer" and re.search(r"\b(part|pn|number|no)\b", nh):
65
+ best *= 0.6 # 'Mfr Part #' is not the maker
66
+ scores.append((best, f, h))
67
+ mapping, used = {}, set()
68
+ for sc, f, h in sorted(scores, key=lambda t: -t[0]):
69
+ if sc >= threshold and f not in mapping and h not in used:
70
+ mapping[f] = h
71
+ used.add(h)
72
+ return mapping
73
+
74
+
75
+ def utf16_without_bom(data):
76
+ """'utf-16-le' / 'utf-16-be' for UTF-16 text without a byte-order mark (mostly-ASCII text: every other byte is
77
+ zero), else None."""
78
+ head = data[:4096]
79
+ if len(head) < 4 or head[:2] in (b"\xff\xfe", b"\xfe\xff"):
80
+ return None
81
+ n = len(head) // 2 * 2
82
+ even, odd = head[0:n:2], head[1:n:2]
83
+ if odd.count(0) > 0.7 * len(odd) and even.count(0) < 0.1 * len(even):
84
+ return "utf-16-le"
85
+ if even.count(0) > 0.7 * len(even) and odd.count(0) < 0.1 * len(odd):
86
+ return "utf-16-be"
87
+ return None
88
+
89
+
90
+ _SI = ((1e-12, "p"), (1e-9, "n"), (1e-6, "u"), (1e-3, "m"))
91
+ _EXP = re.compile(r"[-+]?(\d+(\.\d*)?|\.\d+)[eE][-+]?\d+")
92
+
93
+
94
+ def _plain_number(x):
95
+ """A spreadsheet number as BOM text, never in exponent notation: 1.0 -> '1', 1050170001.0 -> '1050170001',
96
+ 0.1 -> '0.1', 1e-07 -> '100n' (a capacitor typed in farads: SI prefix, as engineers write it)."""
97
+ x = float(x)
98
+ if x != x or x in (float("inf"), float("-inf")):
99
+ return ""
100
+ if x == int(x) and abs(x) < 1e15:
101
+ return str(int(x))
102
+ if 0 < abs(x) < 1e-3:
103
+ for scale, pre in _SI:
104
+ if abs(x) < scale * 1000:
105
+ return f"{x / scale:.6g}{pre}"
106
+ return f"{x:.12f}".rstrip("0").rstrip(".")
107
+
108
+
109
+ def _xl_text(v):
110
+ if v is None or (isinstance(v, float) and v != v):
111
+ return ""
112
+ if isinstance(v, bool):
113
+ return "TRUE" if v else "FALSE"
114
+ if isinstance(v, (int, float)):
115
+ return _plain_number(v)
116
+ if hasattr(v, "isoformat"):
117
+ return v.isoformat()[:10] if getattr(v, "hour", 0) == 0 and getattr(v, "minute", 0) == 0 else v.isoformat()
118
+ return str(v)
119
+
120
+
121
+ def _read_csv(p):
122
+ """Tolerant CSV read: Altium ActiveBOM exports put title rows ('Bill of Materials;;;;;', a blank
123
+ row) above a wider header, which pandas' sniffer chokes on. Rows may be ragged; they are padded."""
124
+ data = Path(p).read_bytes()
125
+ if data[:2] in (b"\xff\xfe", b"\xfe\xff"): # Excel "Unicode Text" is UTF-16 + tabs
126
+ data = data.decode("utf-16").encode("utf-8")
127
+ elif utf16_without_bom(data): # PowerShell / some exporters: no BOM
128
+ data = data.decode(utf16_without_bom(data), errors="replace").encode("utf-8")
129
+ if b"\x00" in data[:4096]:
130
+ raise ValueError(f"{p}: not a text BOM (binary data); save it as CSV or XLSX")
131
+ for enc in ("utf-8-sig", "cp1252", "latin-1"): # Excel 'CSV' on Windows is cp1252
132
+ try:
133
+ text = data.decode(enc)
134
+ break
135
+ except UnicodeDecodeError:
136
+ continue
137
+ lines = [ln for ln in text.splitlines(keepends=True) if ln.strip()] # keepends: a quoted cell may span lines
138
+ if not lines:
139
+ raise ValueError(f"{p}: the BOM file is empty")
140
+
141
+ def score(d): # rows sharing the commonest width > 1
142
+ widths = [len(r) for r in csv.reader(lines[:30], delimiter=d)]
143
+ common = max(set(widths), key=widths.count)
144
+ return (widths.count(common) if common > 1 else 0, common)
145
+
146
+ delim = max(DELIMS, key=score)
147
+ rows = list(csv.reader(lines, delimiter=delim))
148
+ width = max(len(r) for r in rows)
149
+ rows = [[re.sub(r"\s*[\r\n]+\s*", " ", c) for c in r] for r in rows] # 'RES 10k<newline>0603' -> one line
150
+ return pd.DataFrame([r + [""] * (width - len(r)) for r in rows], dtype=str)
151
+
152
+
153
+ def apply_columns(mapping, headers, columns):
154
+ """Rules' [bom] columns (field -> your header, or a list of headers: the first one in this BOM is used) win over
155
+ the fuzzy guess; headers not in this BOM are ignored."""
156
+ for f, want in (columns or {}).items():
157
+ wants = [want] if isinstance(want, str) else list(want)
158
+ h = next((h for w in wants for h in headers if _norm(h) == _norm(w)), None)
159
+ if h is not None:
160
+ mapping = {k: v for k, v in mapping.items() if v != h}
161
+ mapping[f] = h
162
+ return mapping
163
+
164
+
165
+ def read_bom(path, columns=None, rules=None):
166
+ """columns: the rules' [bom] columns ({field: header}), applied over the fuzzy header match. rules: for an Altium
167
+ project, the house parameter names to read the MPN / manufacturer from (default: ecadlib.toml found from the project)."""
168
+ p = Path(path)
169
+ if p.suffix.lower() in (".prjpcb", ".schdoc", ".bomdoc"): # Altium project: BOM straight from the schematics
170
+ import os
171
+ from . import rules as RU
172
+ from .altium_project import bom_lines
173
+ return bom_lines(p, variant=os.environ.get("ECADLIB_VARIANT") or None, rules=RU.resolve(rules, p.parent))
174
+ if p.suffix.lower() == ".kicad_sch" or (p.suffix.lower() in (".xml", ".net") and _BI().is_kicad_netlist(p)):
175
+ bi = _BI() # KiCad schematic / netlist: no header guessing
176
+ return bi.read_kicad_sch(p, columns) if p.suffix.lower() == ".kicad_sch" else bi.read_kicad_netlist(p, columns)
177
+ raw = (_read_excel(p) if p.suffix.lower() in (".xlsx", ".xlsm", ".xls") else _read_csv(p)).fillna("")
178
+ if raw.empty or not raw.astype(str).apply(lambda c: c.str.strip()).ne("").any().any():
179
+ raise ValueError(f"{p}: the BOM file is empty")
180
+ hdr_row = max(range(min(10, len(raw))), key=lambda i: len(apply_columns(map_headers(list(raw.iloc[i])), # header
181
+ [str(h).strip() for h in raw.iloc[i]], columns))) # may not be row 0
182
+ headers = [str(h).strip() for h in raw.iloc[hdr_row]]
183
+ df = raw.iloc[hdr_row + 1:].copy()
184
+ df.columns = headers
185
+ mapping = apply_columns(map_headers(headers), headers, columns)
186
+ if not {"designator", "mpn", "value"} & mapping.keys():
187
+ raise ValueError(f"{p}: no BOM header found (need a Designator, MPN or Value/Comment column)")
188
+ col = {f: headers.index(h) for f, h in mapping.items()} # by position: a BOM may repeat a header (a 2nd 'MPN')
189
+ lines = []
190
+ for _, row in df.iterrows():
191
+ d = {f: SS.unquote(str(row.iloc[j]).strip()) for f, j in col.items()} # "'=X" from our own reports
192
+ if not any(d.values()):
193
+ continue
194
+ if lines and d.get("designator") and not any(v for f, v in d.items() if f != "designator"):
195
+ prev, q = lines[-1], re.match(r"\s*(\d+)", lines[-1].get("qty", ""))
196
+ if q and count_refs(prev["designator"]) < int(q.group(1)):
197
+ prev["designator"] += ", " + d["designator"] # designator list wrapped onto the next row
198
+ continue
199
+ if _EXP.fullmatch(d.get("value", "")): # a CSV that kept Excel's 1e-07
200
+ d["value"] = _plain_number(d["value"])
201
+ if _is_total(d):
202
+ continue # 'Total;;;12' footer, not a part
203
+ if "dnp" in d:
204
+ d["dnp"] = _yes(d["dnp"])
205
+ if d.get("lcsc") and not re.fullmatch(r"C\d+", d["lcsc"], re.I):
206
+ d["lcsc_invalid"] = d.pop("lcsc")
207
+ lines.append(d)
208
+ return lines, mapping
209
+
210
+
211
+ def _BI():
212
+ from . import bom_inputs
213
+ return bom_inputs
214
+
215
+
216
+ def _header_score(raw):
217
+ return max((len(map_headers(list(raw.iloc[i]))) for i in range(min(10, len(raw)))), default=0)
218
+
219
+
220
+ def _read_excel(p):
221
+ """The sheet that holds the BOM: workbooks often put a cover / notes sheet first."""
222
+ head = Path(p).read_bytes()[:8]
223
+ if not head.startswith((b"PK\x03\x04", b"\xd0\xcf\x11\xe0")): # not a workbook: CSV / tab text named .xls(x)
224
+ return _read_csv(p)
225
+ try:
226
+ sheets = [df.map(_xl_text) if hasattr(df, "map") else df.applymap(_xl_text) # numbers as typed: 1e-07 F
227
+ for df in pd.read_excel(p, header=None, dtype=object, sheet_name=None).values()] # -> '100n', not '1e-07'
228
+ except ImportError as e: # old binary .xls needs xlrd (not a dependency)
229
+ if head.startswith(b"PK"): # .xlsx without openpyxl: read it ourselves
230
+ sheets = [pd.DataFrame(rows, dtype=object) for rows in _BI().xlsx_sheets(p).values()]
231
+ return max(sheets, key=_header_score) if sheets else pd.DataFrame()
232
+ raise ValueError(f"{p}: can't read this old .xls workbook ({e}); save it as .xlsx or CSV") from e
233
+ except Exception as e: # truncated / corrupt zip, missing parts
234
+ raise ValueError(f"{p}: not a readable Excel workbook ({type(e).__name__}: {e})") from e
235
+ return max(sheets, key=_header_score) if sheets else pd.DataFrame()
236
+
237
+
238
+ TOTAL = re.compile(r"(grand\s*|sub\s*)?totals?\s*:?", re.I)
239
+
240
+
241
+ def _is_total(d):
242
+ """A totals footer: some cell says 'Total' and the row names no part."""
243
+ return (not any(d.get(f) for f in ("mpn", "lcsc", "value", "libref", "footprint", "manufacturer"))
244
+ and any(TOTAL.fullmatch(str(v).strip()) for v in d.values()))
245
+
246
+
247
+ def _yes(v):
248
+ """A DNP cell is set when it says anything other than blank / no / 0 / false / fitted."""
249
+ return _norm(v) not in ("", "0", "n", "no", "false", "fitted", "populate", "fit")
250
+
251
+
252
+ GENERIC_LIBREF = re.compile(r"(res(istor)?|cap(acitor)?|ind(uctor)?|led|diode|zener|schottky|fuse|bead|ferrite|"
253
+ r"xtal|crystal|header|conn(ector)?|sw(itch)?|[npm]mos|npn|pnp|[rcldq])"
254
+ r"([\s_-]*(pol|semi|var|adj|smd|tvs))*[\s_-]*\d*", re.I) # Altium generic symbols: Res1, Cap Pol1
255
+
256
+
257
+ PLACEHOLDER_MPN = re.compile(r"\s*(n/?a|none|tbd|tba|tbc|dnp|dnf|n\.?c\.?|generic|any|various|not applicable|-+|\?+|x+)\s*", re.I)
258
+
259
+
260
+ def placeholder_mpn(s):
261
+ """True for an MPN cell that names no part ('N/A', 'TBD', 'Generic', '-'): two such cells are not the same part."""
262
+ return bool(PLACEHOLDER_MPN.fullmatch(str(s or "")))
263
+
264
+
265
+ def part_numbers(ln):
266
+ """MPN candidates, best first: the MPN column, then an Altium 'Comment'/value that is not a
267
+ passive value (ICs carry their part number there, e.g. STM32F103C8T6), then the LibRef. Altium vault item ids
268
+ (CMP-1035-02800-1: the LibRef / Comment of Content Vault and Altium 365 parts) are never part numbers."""
269
+ from .altium_project import is_vault_id
270
+ out = [ln.get("mpn", "")]
271
+ v = ln.get("value", "")
272
+ if v and parse_value(v) is None and re.search(r"[A-Za-z]", v) and re.search(r"\d", v) and " " not in v.strip():
273
+ out.append(v)
274
+ lib = ln.get("libref", "").strip()
275
+ if not GENERIC_LIBREF.fullmatch(lib):
276
+ out.append(lib)
277
+ return [c for c in dict.fromkeys(c.strip() for c in out) if c and not is_vault_id(c)]
278
+
279
+
280
+ def count_refs(s):
281
+ """'R1, R2, R5-R8' -> 6 (ranges like R5-R8, R5-8 or 'R5 - R8' count every designator). Units of one multi-unit
282
+ part ('U1A, U1B', Altium's suffixed designators) count once, as does a designator listed twice."""
283
+ s = re.sub(r"(?<=[0-9A-Za-z])\s*[-\u2013\u2014]\s*(?=[A-Za-z]*\d)", "-", s.strip()) # 'R1 – R4' -> 'R1-R4'
284
+ seen, extra = set(), 0
285
+ for tok in re.split(r"[,;\s]+", s):
286
+ m = re.fullmatch(r"([A-Za-z]+)(\d+)[A-Za-z]?(?:-(?:\1)?(\d+)[A-Za-z]?)?", tok)
287
+ if not m:
288
+ if re.fullmatch(r"[A-Za-z]+\d+[A-Za-z0-9_.]*", tok): # Altium channel designators (R1_CH1, R1_A): one each
289
+ seen.add(("", tok.upper()))
290
+ continue
291
+ pre, lo = m.group(1).upper(), int(m.group(2))
292
+ hi = int(m.group(3)) if m.group(3) and int(m.group(3)) >= lo else lo
293
+ if hi - lo > 100000: # absurd range: count it, don't enumerate it
294
+ extra += hi - lo + 1
295
+ else:
296
+ seen.update((pre, k) for k in range(lo, hi + 1))
297
+ return len(seen) + extra
298
+
299
+
300
+ def triage(lines, index, provs, http):
301
+ rows = []
302
+ for i, ln in enumerate(lines, 1):
303
+ if ln.get("dnp"):
304
+ rows.append({"line": i, "designator": ln.get("designator", ""), "qty": ln.get("qty", ""),
305
+ "mpn": ln.get("mpn", ""), "manufacturer": ln.get("manufacturer", ""), "lcsc": ln.get("lcsc", ""),
306
+ "value": ln.get("value", ""), "package": "", "status": "dnp (not fitted)",
307
+ "local_symbol": "", "local_footprint": "", "src": "", "src_symbol": "", "src_footprint": "",
308
+ "src_3d": "", "src_mpn": "", "src_description": "", "notes": "marked do-not-populate; not triaged"})
309
+ continue
310
+ pns = part_numbers(ln)
311
+ pkg = package_of(ln.get("footprint"), ln.get("description"), ln.get("value"), *pns)
312
+ cls = re.match(r"[A-Z]+", ln.get("designator", "").upper())
313
+ sym = how = None
314
+ for pn in pns or [""]:
315
+ sym, how = index.match(pn, ln.get("lcsc", ""), ln.get("value", ""), pkg, ref_class=cls.group(0) if cls else "")
316
+ if sym:
317
+ break
318
+ fp_key = None
319
+ if sym:
320
+ fp_key, _ = index.footprint(sym.props.get("Footprint", ""))
321
+ if not fp_key:
322
+ fp_key, _ = index.footprint(ln.get("footprint", ""))
323
+ gen_fp = None if fp_key else index.find_footprint_for_package(
324
+ pkg, cls.group(0) if cls else "", hint=f"{ln.get('footprint', '')} {ln.get('description', '')}")
325
+ notes, src, errored, work, unconfigured, desc = [], None, False, dict(ln), [], ""
326
+ if pns and not ln.get("mpn"):
327
+ work["mpn"] = pns[0] # let providers search by the Comment/LibRef part number
328
+ notes.append(f"part number taken from {'Comment/value' if pns[0] == ln.get('value', '').strip() else 'LibRef'}: {pns[0]}")
329
+ if ln.get("altium_mpn_source") == "Comment":
330
+ notes.append("MPN taken from the schematic Comment (the component has no Manufacturer Part Number parameter)")
331
+ if ln.get("lcsc_invalid"):
332
+ notes.append(f"ignored malformed LCSC '{ln['lcsc_invalid']}'")
333
+ if not (sym and fp_key):
334
+ for prov in provs:
335
+ if isinstance(prov, P.JLCPCBSearch) and work.get("lcsc"):
336
+ continue # already have an LCSC number
337
+ if isinstance(prov, P.EasyEDA) and not work.get("lcsc"):
338
+ continue # EasyEDA is keyed by LCSC number only
339
+ try:
340
+ r = prov.lookup(work)
341
+ except P.NotConfigured:
342
+ unconfigured.append(prov.name)
343
+ continue
344
+ except NotImplementedError as e:
345
+ notes.append(str(e)[:80])
346
+ continue
347
+ except Exception as e: # a third-party plugin (ecadlib.plugins) never stops triage
348
+ if not getattr(prov, "_plugin", False):
349
+ raise
350
+ r = P.SourceResult(prov.name, error=f"plugin error: {type(e).__name__}: {str(e)[:120]}")
351
+ if not isinstance(r, P.SourceResult):
352
+ r = P.SourceResult(getattr(prov, "name", "?"), error="plugin returned no SourceResult")
353
+ if r.error:
354
+ errored = True
355
+ notes.append(f"{prov.name}: {r.error}")
356
+ elif isinstance(prov, P.JLCPCBSearch):
357
+ if r.found:
358
+ work["lcsc"] = r.lcsc
359
+ desc = desc or (r.extra or {}).get("description") or ""
360
+ notes.append(f"jlcpcb: MPN -> {r.lcsc}" if r.found else "jlcpcb: no exact MPN match")
361
+ else:
362
+ src = r
363
+ if r.found:
364
+ desc = desc or (r.extra or {}).get("description") or ""
365
+ if r.found and ln.get("mpn") and r.mpn and _n(r.mpn) != _n(ln["mpn"]):
366
+ notes.append(f"MPN mismatch: BOM {ln['mpn']} vs {prov.name} {r.mpn}")
367
+ if r.found and r.has_footprint:
368
+ break
369
+
370
+ if sym and fp_key:
371
+ status = "in_library"
372
+ elif sym and gen_fp:
373
+ status = "in_library (generated footprint)"
374
+ elif src and src.found and src.has_footprint:
375
+ status = "available_easyeda" if src.has_symbol else "easyeda_footprint_only"
376
+ elif errored:
377
+ status = "unverified (source unreachable)"
378
+ else:
379
+ status = "needs_footprint_generation"
380
+ if unconfigured:
381
+ notes.append("not configured: " + ",".join(unconfigured))
382
+ if sym and not fp_key:
383
+ notes.append(f"local symbol OK but footprint '{sym.props.get('Footprint', '')}' not in indexed libs")
384
+ if gen_fp and not sym:
385
+ notes.append(f"generated footprint exists: {gen_fp}")
386
+ rows.append({
387
+ "line": i, "designator": ln.get("designator", ""), "qty": ln.get("qty", ""),
388
+ "mpn": ln.get("mpn") or work.get("mpn", ""), "manufacturer": ln.get("manufacturer", ""), "lcsc": ln.get("lcsc") or (f"{work['lcsc']} (via MPN)" if work.get("lcsc") else ""),
389
+ "value": ln.get("value", ""), "package": pkg or "",
390
+ "status": status,
391
+ "local_symbol": f"{sym.lib}:{sym.name} ({how})" if sym else "",
392
+ "local_footprint": fp_key or (f"{gen_fp} (generated, by package)" if gen_fp else ""),
393
+ "src": src.provider if src else "", "src_symbol": _yn(src and src.has_symbol),
394
+ "src_footprint": _yn(src and src.has_footprint), "src_3d": _yn(src and src.has_3d),
395
+ "src_mpn": src.mpn if src else "", "src_description": desc, "notes": "; ".join(dict.fromkeys(notes)),
396
+ })
397
+ return rows
398
+
399
+
400
+ def _n(s):
401
+ return re.sub(r"[^A-Z0-9]", "", s.upper())
402
+
403
+
404
+ def _yn(v):
405
+ return "" if v is None or v == "" else ("yes" if v else "no")
406
+
407
+
408
+ def write_reports(rows, mapping, provs, http, out, bom):
409
+ out = Path(out)
410
+ out.parent.mkdir(parents=True, exist_ok=True)
411
+ with open(out.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
412
+ w = SS.DictWriter(f, fieldnames=list(rows[0]) if rows else ["line", "status"]) # no formulas from BOM cells
413
+ w.writeheader()
414
+ w.writerows(rows)
415
+ counts = pd.Series([r["status"] for r in rows]).value_counts()
416
+ md = [f"# BOM triage - `{bom}`", "", "## Summary", ""]
417
+ md += [f"- **{k}**: {v}" for k, v in counts.items()]
418
+ md += ["", "## Header mapping", "", "| field | BOM column |", "|---|---|"]
419
+ md += [f"| {k} | {v} |" for k, v in mapping.items()]
420
+ md += ["", "## Lines", "", "| # | Ref | MPN | LCSC | Pkg | Status | Local symbol | Local footprint | Src sym/fp/3D | Notes |",
421
+ "|---|---|---|---|---|---|---|---|---|---|"]
422
+ for r in rows:
423
+ s3 = "/".join(x or "-" for x in (r["src_symbol"], r["src_footprint"], r["src_3d"]))
424
+ md.append(f"| {r['line']} | {r['designator'][:18]} | {r['mpn']} | {r['lcsc']} | {r['package']} | {r['status']} | "
425
+ f"{r['local_symbol']} | {r['local_footprint']} | {s3} | {r['notes']} |")
426
+ md += ["", "## Providers", "", "| provider | configured | auth |", "|---|---|---|"]
427
+ md += [f"| {p.name} | {'yes' if p.configured() else 'no'} | {p.auth} |" for p in provs]
428
+ hosts = {}
429
+ for m, u, st, cached in http.log:
430
+ h = re.sub(r"^https?://([^/]+).*", r"\1", u)
431
+ hosts.setdefault(h, []).append(("cache " if cached else "") + str(st))
432
+ md += ["", "## HTTP (this run)", ""] + [f"- `{h}`: {len(v)} requests -> {sorted(set(v))}" for h, v in hosts.items()]
433
+ out.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
434
+ return counts
435
+
436
+
437
+ def main(argv=None):
438
+ ap = argparse.ArgumentParser(description="Triage a BOM against a local KiCad library and part sources")
439
+ ap.add_argument("bom")
440
+ ap.add_argument("--lib", action="append", default=[], help="KiCad library dir (repeatable)")
441
+ ap.add_argument("-o", "--out", default="out/triage", help="report path stem (.md and .csv written)")
442
+ ap.add_argument("--providers", help="comma list in order (default: [providers] order in the rules, else "
443
+ "jlcpcb_search,easyeda,digikey,mouser,nexar,snapmagic,samacsys); plugins: `ecadlib plugins list`")
444
+ ap.add_argument("--offline", action="store_true", help="use HTTP cache only")
445
+ ap.add_argument("--cache", default="out/http_cache")
446
+ ap.add_argument("--rate", type=float, default=1.0, help="min seconds between requests per host")
447
+ ap.add_argument("--rules", help="library rules file; its [bom] columns name your BOM headers (default: ecadlib.toml found from here)")
448
+ ap.add_argument("--json", action="store_true", help="print the result as JSON (bom, lines, mapping, counts, rows, reports)")
449
+ a = ap.parse_args(argv)
450
+
451
+ from . import rules as RU
452
+ from . import plugins as PL
453
+ http = CachedHTTP(a.cache, min_interval=a.rate, offline=a.offline)
454
+ rules = RU.load(a.rules, start=Path(a.out).parent)
455
+ try:
456
+ provs = [c(http) for c in PL.select(a.providers, rules)]
457
+ except ValueError as e:
458
+ print(f"ecadlib triage: {e}", file=sys.stderr)
459
+ return 2
460
+ from .firstrun import bom_hint
461
+ try:
462
+ lines, mapping = read_bom(a.bom, RU.bom_columns(rules), rules)
463
+ except (ValueError, OSError) as e: # unreadable / headerless BOM: a message, not a traceback
464
+ print(f"cannot read BOM: {e}" + bom_hint(e), file=sys.stderr)
465
+ return 2
466
+ if not lines:
467
+ print(f"cannot read BOM: {a.bom}: the BOM has a header but no part rows" + bom_hint("no part rows"), file=sys.stderr)
468
+ return 2
469
+ index = KiCadIndex(a.lib)
470
+ say = (lambda *x: None) if a.json else print
471
+ from . import term as TM
472
+ say(f"BOM: {len(lines)} lines; columns: " + ", ".join(f"{k} = {v!r}" for k, v in mapping.items()))
473
+ if a.lib or TM.VERBOSITY > 0:
474
+ say(f"your KiCad libraries: {len(index.symbols)} symbols, {len(index.footprints)} footprints")
475
+ rows = triage(lines, index, provs, http)
476
+ counts = write_reports(rows, mapping, provs, http, a.out, a.bom)
477
+ if a.json: # stable shape: README "Machine-readable output (--json)"
478
+ print(json.dumps({"bom": str(a.bom), "lines": len(rows), "mapping": mapping,
479
+ "counts": {str(k): int(v) for k, v in counts.items()}, "rows": rows,
480
+ "reports": [Path(a.out).with_suffix(x).as_posix() for x in (".md", ".csv")]}, indent=2, default=str))
481
+ return 0
482
+ print_result(rows, counts, a)
483
+
484
+
485
+ TRIAGE_WORDS = { # triage status -> (term status, plain words)
486
+ "in_library": ("ready", "in your library"),
487
+ "in_library (generated footprint)": ("ready", "in your library (footprint generated)"),
488
+ "available_easyeda": ("signoff", "found online (EasyEDA)"),
489
+ "easyeda_footprint_only": ("held", "footprint online, no symbol"),
490
+ "unverified (source unreachable)": ("skipped", "not checked: source unreachable"),
491
+ "needs_footprint_generation": ("held", "needs a new footprint"),
492
+ }
493
+
494
+
495
+ def print_result(rows, counts, a, t=None):
496
+ """Terminal view: a row per BOM line in plain words (-v: the source notes), the counts, the next step."""
497
+ from . import messages as M, term as TM
498
+ t = t or TM.Term()
499
+ table = []
500
+ for r in rows:
501
+ st, word = TRIAGE_WORDS.get(r["status"], ("info", r["status"].replace("_", " ")))
502
+ table.append([str(r["line"]), t.status(st, word), r["mpn"][:32], r["lcsc"],
503
+ M.clean(r["notes"])[:200] if t.verbose else ""])
504
+ t.table(table, right=(0,))
505
+ by = {TRIAGE_WORDS.get(str(k), ("", str(k)))[1]: int(v) for k, v in counts.items()}
506
+ unreach = sum(int(v) for k, v in counts.items() if str(k).startswith("unverified"))
507
+ notes = []
508
+ if unreach:
509
+ notes.append((f"{M.plural(unreach, 'line')} could not be checked: the part sources were not reachable"
510
+ + (" (--offline: only the cache)" if a.offline else ""),
511
+ "run again online, or add API keys (`ecadlib doctor` lists them)"))
512
+ stem = Path(a.out)
513
+ t.summary(f"{M.plural(len(rows), 'BOM line')}: " + ", ".join(f"{v} {k}" for k, v in by.items()),
514
+ written=[(str(stem.with_suffix(".md")), "triage report"), (str(stem.with_suffix(".csv")), "the same as a table")],
515
+ next=[(f"ecadlib librarian {a.bom} -o ws/librarian/board", "fill the missing parts and build the library")],
516
+ notes=notes)
517
+
518
+ if __name__ == "__main__":
519
+ sys.exit(main())