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_inputs.py ADDED
@@ -0,0 +1,339 @@
1
+ """More BOM inputs for bom_triage.read_bom: KiCad schematics and netlists, and .xlsx without openpyxl.
2
+
3
+ read_kicad_sch(path) .kicad_sch (KiCad 6-9), following hierarchical sheets (a sheet used twice counts twice)
4
+ read_kicad_netlist(p) KiCad netlist / BOM export: XML (`kicad-cli sch export netlist --format kicadxml`,
5
+ `sch export python-bom`, Eeschema's "Generate BOM" intermediate file) or S-expression (.net)
6
+ xlsx_sheets(path) every sheet of an .xlsx as rows of text, using only zipfile + ElementTree
7
+
8
+ Each reader returns (lines, mapping) in the shape read_bom returns for a CSV: one line per group of identical parts
9
+ ({"designator": "R1, R2", "qty": "2", "value": ..., "footprint": ..., "mpn": ..., "manufacturer": ..., "dnp": bool}),
10
+ mapping = {field: the KiCad field it came from}. Custom fields (MPN, "Mfr Part #", LCSC, ...) are matched with
11
+ bom_triage.map_headers and the rules' [bom] columns, exactly like CSV headers. Like KiCad's own BOM, power symbols
12
+ (#PWR, #FLG) and symbols marked "exclude from BOM" are left out; "Do not populate" becomes dnp."""
13
+ import re, sys, zipfile
14
+ from pathlib import Path
15
+ from xml.etree import ElementTree as ET
16
+
17
+ from . import sexpr
18
+
19
+ STD = {"Reference": "designator", "Value": "value", "Footprint": "footprint", "Datasheet": "datasheet",
20
+ "Description": "description"}
21
+ SKIP_FIELD = re.compile(r"(ki_.*|Sim\..*|Spice_.*|Sheetname|Sheetfile|Sheet name|Sheet file|exclude_from_.*)", re.I)
22
+ MAX_SHEETS = 2000
23
+
24
+
25
+ def _natkey(ref):
26
+ return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", ref)]
27
+
28
+
29
+ def _field_map(names, columns):
30
+ """{field: [KiCad field names]} for the custom fields, matched like CSV headers. Several names for one field
31
+ (MPN on one sheet, "Mfr Part #" on another) are all kept: a part's value is the first one it has. The rules'
32
+ [bom] columns (a name or a list of names) replace the guess for that field."""
33
+ from .bom_triage import _norm, map_headers
34
+ custom = sorted({n for n in names if n not in STD and not SKIP_FIELD.fullmatch(n)})
35
+ skip = ("qty", "designator", "value", "footprint", "datasheet")
36
+ fmap = {f: [h] for f, h in map_headers(custom).items() if f not in skip}
37
+ taken = {h for hs in fmap.values() for h in hs}
38
+ for n in custom: # other names that on their own mean the same field
39
+ for f, h in map_headers([n]).items():
40
+ if f in fmap and n not in taken:
41
+ fmap[f].append(n)
42
+ taken.add(n)
43
+ for f, want in (columns or {}).items():
44
+ wants = [want] if isinstance(want, str) else list(want)
45
+ hit = [n for w in wants for n in custom + list(STD) if _norm(n) == _norm(w)]
46
+ if hit:
47
+ fmap = {g: [x for x in hs if x not in hit] for g, hs in fmap.items()}
48
+ fmap[f] = list(dict.fromkeys(hit))
49
+ return {f: hs for f, hs in fmap.items() if hs}
50
+
51
+
52
+ def group(parts, columns=None, source="KiCad"):
53
+ """parts: [{"ref", "fields": {name: value}, "dnp": bool}] -> (lines, mapping), identical parts grouped."""
54
+ names = {n for p in parts for n in p["fields"]}
55
+ custom = _field_map(names, columns)
56
+ fmap = {f: [k] for k, f in STD.items() if k in names or f in ("designator", "value")}
57
+ fmap.update(custom)
58
+ groups = {}
59
+ for p in parts:
60
+ ln = {f: next((v for v in (str(p["fields"].get(k, "")).strip() for k in ks) if v), "")
61
+ for f, ks in fmap.items() if f != "designator"}
62
+ if ln.get("datasheet") == "~":
63
+ ln["datasheet"] = ""
64
+ dnp = p["dnp"]
65
+ if "dnp" in ln: # a house "DNP" field as well as KiCad's flag
66
+ from .bom_triage import _yes
67
+ dnp = dnp or _yes(ln.pop("dnp"))
68
+ key = (tuple(sorted(ln.items())), dnp)
69
+ groups.setdefault(key, (ln, dnp, []))[2].append(p["ref"])
70
+ lines = []
71
+ for ln, dnp, refs in groups.values():
72
+ n = len(refs) if any(r.endswith("?") for r in refs) else len(set(refs)) # 'R?' twice is two parts
73
+ refs = sorted(dict.fromkeys(refs), key=_natkey)
74
+ d = {"designator": ", ".join(refs), "qty": str(n)}
75
+ d.update({k: v for k, v in ln.items() if v})
76
+ d["dnp"] = dnp
77
+ if d.get("lcsc") and not re.fullmatch(r"C\d+", d["lcsc"], re.I):
78
+ d["lcsc_invalid"] = d.pop("lcsc")
79
+ lines.append(d)
80
+ lines.sort(key=lambda d: _natkey(d["designator"].split(",")[0]))
81
+ mapping = {f: " | ".join(ks) for f, ks in fmap.items()}
82
+ if not lines:
83
+ raise ValueError(f"no parts found in the {source} file (only power symbols or parts excluded from the BOM)")
84
+ mapping["qty"] = "(count of references)"
85
+ if "dnp" not in mapping:
86
+ mapping["dnp"] = "Do not populate (KiCad flag)"
87
+ return lines, mapping
88
+
89
+
90
+ # ------------------------------------------------------------------ .kicad_sch
91
+ def _val(node, head, default=None):
92
+ n = sexpr.first(node, head)
93
+ return n[1] if n and len(n) > 1 else default
94
+
95
+
96
+ def _props(node):
97
+ return {str(p[1]): str(p[2]) for p in sexpr.find(node, "property") if len(p) > 2}
98
+
99
+
100
+ def _load_sch(path):
101
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
102
+ if text.lstrip().startswith("EESchema Schematic File"):
103
+ raise ValueError(f"{Path(path).name}: a KiCad 5 schematic (.sch); open it in KiCad 6 or later and save it, "
104
+ "or export a BOM / netlist from KiCad")
105
+ try:
106
+ tree = sexpr.parse(text)
107
+ except ValueError as e:
108
+ raise ValueError(f"{Path(path).name}: not a readable KiCad schematic ({e})") from e
109
+ if not isinstance(tree, list) or not tree or tree[0] != "kicad_sch":
110
+ raise ValueError(f"{Path(path).name}: not a KiCad schematic (no kicad_sch header)")
111
+ return tree
112
+
113
+
114
+ def read_kicad_sch(path, columns=None):
115
+ """BOM lines from a KiCad 6+ schematic and all its sub-sheets."""
116
+ path = Path(path)
117
+ root = _load_sch(path)
118
+ root_uuid = _val(root, "uuid", "")
119
+ legacy = {} # KiCad 6: (symbol_instances (path "/sheet/sym" (reference ..)))
120
+ for p in sexpr.find(sexpr.first(root, "symbol_instances") or [], "path"):
121
+ legacy[tuple(x for x in str(p[1]).split("/") if x)] = {"reference": _val(p, "reference"), "unit": _val(p, "unit"),
122
+ "value": _val(p, "value"), "footprint": _val(p, "footprint")}
123
+ parts, seen_refs, cache, count = [], set(), {}, [0]
124
+
125
+ def instance_ref(sym, sheet_path):
126
+ props = _props(sym)
127
+ for proj in sexpr.find(sexpr.first(sym, "instances") or [], "project"): # KiCad 7+: per sheet path
128
+ for p in sexpr.find(proj, "path"):
129
+ parts_ = [x for x in str(p[1]).split("/") if x]
130
+ if parts_ and parts_[0] == root_uuid:
131
+ parts_ = parts_[1:]
132
+ if tuple(parts_) == sheet_path:
133
+ return _val(p, "reference") or props.get("Reference", ""), {}
134
+ old = legacy.get(sheet_path + (_val(sym, "uuid", ""),))
135
+ if old:
136
+ return old["reference"] or props.get("Reference", ""), {k: old[k] for k in ("value", "footprint") if old.get(k)}
137
+ return props.get("Reference", ""), {}
138
+
139
+ def walk(file, sheet_path, chain):
140
+ count[0] += 1
141
+ if count[0] > MAX_SHEETS:
142
+ raise ValueError(f"{path.name}: more than {MAX_SHEETS} sheet instances; is the hierarchy recursive?")
143
+ rf = file.resolve()
144
+ if rf in chain:
145
+ raise ValueError(f"{path.name}: sheet {file.name} includes itself (recursive hierarchy)")
146
+ if rf not in cache:
147
+ cache[rf] = root if rf == path.resolve() else _load_sch(file)
148
+ tree = cache[rf]
149
+ for sym in sexpr.find(tree, "symbol"):
150
+ if _val(sym, "in_bom", "yes") == "no" or _val(sym, "exclude_from_bom", "no") == "yes":
151
+ continue
152
+ ref, over = instance_ref(sym, sheet_path)
153
+ if not ref or ref.startswith("#"): # power symbols and flags
154
+ continue
155
+ if ref.endswith("?"): # unannotated: every symbol is its own part (two 'R?'
156
+ if str(_val(sym, "unit", "1")) != "1": # are two resistors); only units 2.. of a multi-unit
157
+ continue # part are left out, they belong to its unit 1
158
+ key = (ref, sheet_path, _val(sym, "uuid", "") or id(sym))
159
+ else:
160
+ key = ref
161
+ if key in seen_refs: # the other units of a multi-unit part
162
+ continue
163
+ seen_refs.add(key)
164
+ fields = _props(sym)
165
+ fields.update({"Value" if k == "value" else "Footprint": v for k, v in over.items()})
166
+ fields.pop("Reference", None)
167
+ parts.append({"ref": ref, "fields": fields, "dnp": _val(sym, "dnp", "no") == "yes",
168
+ "sheet": "/".join(sheet_path)})
169
+ for sh in sexpr.find(tree, "sheet"):
170
+ props = _props(sh)
171
+ sub = props.get("Sheetfile") or props.get("Sheet file")
172
+ if not sub:
173
+ continue
174
+ target = (file.parent / sub)
175
+ if not target.is_file():
176
+ raise ValueError(f"{path.name}: sub-sheet {sub!r} (sheet {props.get('Sheetname', '?')!r}) not found next to {file.name}")
177
+ walk(target, sheet_path + (_val(sh, "uuid", ""),), chain | {rf})
178
+
179
+ walk(path, (), frozenset())
180
+ unannotated = sorted({p["ref"] for p in parts if p["ref"].endswith("?")})
181
+ if unannotated:
182
+ print(f" warning: {path.name}: unannotated references ({', '.join(unannotated[:5])}); annotate the schematic "
183
+ "for a reliable BOM", file=sys.stderr)
184
+ return group(parts, columns, "KiCad schematic")
185
+
186
+
187
+ # ------------------------------------------------------------------ KiCad netlist (XML or S-expression)
188
+ def _xml_root(data, name):
189
+ head = data[:4096].lower()
190
+ if b"<!doctype" in head or b"<!entity" in data.lower():
191
+ raise ValueError(f"{name}: XML with a DOCTYPE/ENTITY is not accepted (KiCad never writes one)")
192
+ try:
193
+ return ET.fromstring(data)
194
+ except ET.ParseError as e:
195
+ raise ValueError(f"{name}: not readable XML ({e})") from e
196
+
197
+
198
+ def read_kicad_netlist(path, columns=None):
199
+ path = Path(path)
200
+ data = path.read_bytes()
201
+ if data.lstrip()[:1] == b"(":
202
+ return _read_net_sexpr(path, data.decode("utf-8", errors="replace"), columns)
203
+ root = _xml_root(data, path.name)
204
+ if root.tag != "export" or root.find("components") is None:
205
+ raise ValueError(f"{path.name}: not a KiCad netlist / BOM XML (expected <export><components>)")
206
+ parts = []
207
+ for c in root.find("components").findall("comp"):
208
+ props = {p.get("name"): p.get("value", "") for p in c.findall("property")}
209
+ if "exclude_from_bom" in props:
210
+ continue
211
+ ref = c.get("ref", "")
212
+ if not ref or ref.startswith("#"):
213
+ continue
214
+ fields = {"Value": c.findtext("value", ""), "Footprint": c.findtext("footprint", ""),
215
+ "Datasheet": c.findtext("datasheet", "")}
216
+ lib = c.find("libsource")
217
+ desc = c.findtext("description") or props.get("ki_description") or (lib.get("description", "") if lib is not None else "")
218
+ if desc:
219
+ fields["Description"] = desc
220
+ for f in c.findall("fields/field"):
221
+ fields.setdefault(f.get("name", ""), (f.text or "").strip())
222
+ if f.get("name") in ("Value", "Footprint", "Datasheet", "Description") and (f.text or "").strip():
223
+ fields[f.get("name")] = f.text.strip()
224
+ parts.append({"ref": ref, "fields": fields, "dnp": "dnp" in props})
225
+ return group(parts, columns, "KiCad netlist")
226
+
227
+
228
+ def _read_net_sexpr(path, text, columns):
229
+ try:
230
+ tree = sexpr.parse(text)
231
+ except ValueError as e:
232
+ raise ValueError(f"{path.name}: not a readable KiCad netlist ({e})") from e
233
+ if not tree or tree[0] != "export":
234
+ raise ValueError(f"{path.name}: not a KiCad netlist (expected (export ...))")
235
+ parts = []
236
+ for c in sexpr.find(sexpr.first(tree, "components") or [], "comp"):
237
+ props = {_val(p, "name"): _val(p, "value", "") for p in sexpr.find(c, "property")}
238
+ ref = _val(c, "ref", "")
239
+ if "exclude_from_bom" in props or not ref or ref.startswith("#"):
240
+ continue
241
+ fields = {"Value": _val(c, "value", ""), "Footprint": _val(c, "footprint", ""), "Datasheet": _val(c, "datasheet", "")}
242
+ desc = _val(c, "description") or props.get("ki_description") or _val(sexpr.first(c, "libsource") or [], "description")
243
+ if desc:
244
+ fields["Description"] = desc
245
+ for f in sexpr.find(sexpr.first(c, "fields") or [], "field"):
246
+ name = _val(f, "name", "")
247
+ v = next((x for x in f[1:] if not isinstance(x, list)), "")
248
+ fields.setdefault(name, str(v).strip())
249
+ parts.append({"ref": ref, "fields": fields, "dnp": "dnp" in props})
250
+ return group(parts, columns, "KiCad netlist")
251
+
252
+
253
+ def is_kicad_netlist(path):
254
+ """True for a KiCad XML / S-expression netlist or BOM intermediate file (by content, not just the name)."""
255
+ try:
256
+ head = Path(path).read_bytes()[:2048]
257
+ except OSError:
258
+ return False
259
+ return bool(re.search(rb"^\s*(<\?xml[^>]*>\s*)?<export\b", head) or re.match(rb"\s*\(export\s", head))
260
+
261
+
262
+ # ------------------------------------------------------------------ .xlsx without openpyxl
263
+ NS = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
264
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
265
+ "rel": "http://schemas.openxmlformats.org/package/2006/relationships"}
266
+ MAX_CELLS = 2_000_000
267
+
268
+
269
+ def _col(ref):
270
+ n = 0
271
+ for ch in re.match(r"[A-Z]*", ref).group(0):
272
+ n = n * 26 + ord(ch) - 64
273
+ return n - 1
274
+
275
+
276
+ def xlsx_sheets(path):
277
+ """{sheet name: rows (lists of text)} read with the standard library only. Numbers come back as BOM text the
278
+ way bom_triage writes them (1e-07 -> '100n', 3.0 -> '3'); dates stay as Excel serial numbers."""
279
+ from .bom_triage import _plain_number
280
+ path = Path(path)
281
+ try:
282
+ z = zipfile.ZipFile(path)
283
+ except zipfile.BadZipFile as e:
284
+ raise ValueError(f"{path.name}: not a readable .xlsx workbook ({e})") from e
285
+ with z:
286
+ names = set(z.namelist())
287
+
288
+ def xml(name):
289
+ return _xml_root(z.read(name), f"{path.name}:{name}")
290
+
291
+ shared = []
292
+ if "xl/sharedStrings.xml" in names:
293
+ for si in xml("xl/sharedStrings.xml").findall("m:si", NS):
294
+ shared.append("".join(t.text or "" for t in si.iter(f"{{{NS['m']}}}t")))
295
+ rels = {}
296
+ if "xl/_rels/workbook.xml.rels" in names:
297
+ for r in xml("xl/_rels/workbook.xml.rels").findall("rel:Relationship", NS):
298
+ t = r.get("Target", "")
299
+ rels[r.get("Id")] = t.lstrip("/") if t.startswith("/") else "xl/" + t
300
+ out, cells = {}, 0
301
+ for i, s in enumerate(xml("xl/workbook.xml").findall("m:sheets/m:sheet", NS)):
302
+ target = rels.get(s.get(f"{{{NS['r']}}}id"), f"xl/worksheets/sheet{i + 1}.xml")
303
+ if target not in names:
304
+ continue
305
+ rows = {}
306
+ for row in xml(target).findall("m:sheetData/m:row", NS):
307
+ r = int(row.get("r", len(rows) + 1)) - 1
308
+ vals = {}
309
+ for j, c in enumerate(row.findall("m:c", NS)):
310
+ cells += 1
311
+ if cells > MAX_CELLS:
312
+ raise ValueError(f"{path.name}: more than {MAX_CELLS} cells; is this really a BOM?")
313
+ col = _col(c.get("r", "")) if c.get("r") else j
314
+ t, v = c.get("t", "n"), c.findtext("m:v", None, NS)
315
+ if t == "s" and v is not None:
316
+ text = shared[int(v)] if int(v) < len(shared) else ""
317
+ elif t == "inlineStr":
318
+ text = "".join(x.text or "" for x in c.iter(f"{{{NS['m']}}}t"))
319
+ elif t == "b":
320
+ text = "TRUE" if v == "1" else "FALSE"
321
+ elif t in ("str", "e"):
322
+ text = v or ""
323
+ elif v is not None:
324
+ try:
325
+ text = _plain_number(v)
326
+ except ValueError:
327
+ text = v
328
+ else:
329
+ text = ""
330
+ vals[col] = text
331
+ if vals:
332
+ rows[r] = vals
333
+ if not rows:
334
+ out[s.get("name", f"Sheet{i + 1}")] = []
335
+ continue
336
+ width = max(max(v) for v in rows.values()) + 1
337
+ out[s.get("name", f"Sheet{i + 1}")] = [[rows.get(r, {}).get(c, "") for c in range(width)]
338
+ for r in range(max(rows) + 1)]
339
+ return out