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_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())
|