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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- 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
|