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/dedupe.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""Find duplicate parts across a plan and the libraries you already have; optionally merge plan references.
|
|
2
|
+
|
|
3
|
+
python3 -m ecadlib.dedupe out/librarian/plan.json [--lib out/altium_models --lib out/altium/ecadlib_sqlite.DbLib ...]
|
|
4
|
+
[--a365 out/a365_state.json] [--config dedupe.json] [--keep NAME] [--merge [--kinds mpn,footprint,symbol]]
|
|
5
|
+
[--bom BOM.csv] [-o out/librarian/dedupe]
|
|
6
|
+
(or: ecadlib dedupe PLAN ...)
|
|
7
|
+
|
|
8
|
+
What it compares (each plan item, plus everything under --lib: .SchLib, .PcbLib, .kicad_mod, .DbLib with its SQLite
|
|
9
|
+
or Excel file, .sqlite/.db/.xlsx tables, and the Altium 365 component list from a check-a365 --state file, which is
|
|
10
|
+
read from <plan dir>/a365_state.json when present):
|
|
11
|
+
mpn same normalised MPN under different part names. Normalising upper-cases, strips packaging suffixes
|
|
12
|
+
(/TR, -TR, T&R, -REEL7, #TRPBF, (LF)(SN), NXP ',115', ...) and then all punctuation. Suffixes without a
|
|
13
|
+
separator (TI's ...RGTR vs ...RGTT) are NOT stripped by default; add patterns in the config.
|
|
14
|
+
footprint identical footprint geometry under different footprint names: pads (number, position, effective size,
|
|
15
|
+
drilled or not) to 0.01 mm and the courtyard box to 0.05 mm; .PcbLib and .kicad_mod hash alike
|
|
16
|
+
(KiCad's y axis is flipped). Same name with different geometry is reported as a conflict.
|
|
17
|
+
symbol identical symbol pinout under different symbol names: designator prefix plus (pin number, pin name)
|
|
18
|
+
for every pin. Symbols with fewer than symbol_min_pins (default 3) pins are not compared: every
|
|
19
|
+
resistor and capacitor would match.
|
|
20
|
+
Each group gets a recommended keeper: --keep wins, then an Altium 365 component, then an existing library entry,
|
|
21
|
+
then the one most plan items use, a plan item that is ready, a part from the official KiCad library, the shortest
|
|
22
|
+
name. --merge rewrites the plan (the first copy is saved as plan.json.pre-dedupe, later ones timestamped): a duplicate plan item gets
|
|
23
|
+
`duplicate_of` and status `duplicate` (altium365 push skips it, release_check keeps it), a duplicate footprint or
|
|
24
|
+
symbol reference is pointed at the keeper's ref and file (`merged_from` keeps the old one). Library files, DbLib rows
|
|
25
|
+
and workspace components are never changed or deleted; they are only reported. Approvals of a rewritten item go
|
|
26
|
+
stale, as for any other change.
|
|
27
|
+
Outputs: <out>.md, <out>.csv (one row per group member), <out>_map.csv (kind, from, to: the libref / footprint /
|
|
28
|
+
part-number mapping for BOMs), and with --bom a copy of the BOM with its LibRef and Footprint columns remapped.
|
|
29
|
+
Config (dedupe.json next to the plan, or --config): {"strip_suffixes": [regex, ...], "default_suffixes": true,
|
|
30
|
+
"symbol_min_pins": 3, "keep": [names], "ignore": [names]}. Exit codes: 0 no duplicates, 1 duplicates found, 2 error."""
|
|
31
|
+
import argparse, configparser, contextlib, csv, datetime, hashlib, json, math, os, re, shutil, sqlite3, sys
|
|
32
|
+
from pathlib import Path, PureWindowsPath
|
|
33
|
+
|
|
34
|
+
from . import altium_lib as AL, sexpr
|
|
35
|
+
from . import sheetsafe as SS
|
|
36
|
+
from .kicad_index import norm_mpn
|
|
37
|
+
from .fsutil import write_text_atomic
|
|
38
|
+
|
|
39
|
+
DEFAULT_SUFFIXES = [
|
|
40
|
+
r"#(TR)?PBF$", r"#TR$", # Analog Devices / Linear Technology
|
|
41
|
+
r"(?<=[0-9A-Z])TRPBF$", r"(?<=\d[A-Z])PBF$|(?<=\d)PBF$", # Infineon/IR lead-free (+ tape and reel)
|
|
42
|
+
r"(\((LF|SN|PBF|PB-?FREE)\))+$", # JST (LF)(SN)
|
|
43
|
+
r"[-/ _](TR|T/R|T&R|TR7|TR13|R7|R13|TAPE|REEL|CT|CUT ?TAPE|BULK|TRAY|TUBE)$",
|
|
44
|
+
r"-?REEL(7|13)?$", r"(T&R|T/R)$", r",\d{3}$", # ADI -REEL7, NXP packing code ,115
|
|
45
|
+
]
|
|
46
|
+
CONFIG = {"strip_suffixes": [], "default_suffixes": True, "symbol_min_pins": 3, "keep": [], "ignore": []}
|
|
47
|
+
MPN_COLS = ("Manufacturer Part Number", "MPN", "Mfr Part Number")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_config(path=None, base=None):
|
|
51
|
+
f = Path(path) if path else (Path(base) / "dedupe.json" if base else None)
|
|
52
|
+
raw = json.loads(f.read_text(encoding="utf-8")) if f and f.exists() else {}
|
|
53
|
+
bad = set(raw) - set(CONFIG)
|
|
54
|
+
if bad:
|
|
55
|
+
raise ValueError(f"{f}: unknown keys {sorted(bad)}; known: {sorted(CONFIG)}")
|
|
56
|
+
cfg = {**CONFIG, **raw}
|
|
57
|
+
cfg["patterns"] = [re.compile(p, re.I) for p in (DEFAULT_SUFFIXES if cfg["default_suffixes"] else []) + list(cfg["strip_suffixes"])]
|
|
58
|
+
return cfg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mpn_key(s, cfg=None):
|
|
62
|
+
"""'LM1117MPX-3.3/NOPB/TR' -> 'LM1117MPX33NOPB'; '' for an empty MPN or a placeholder ('N/A', 'TBD'): parts whose
|
|
63
|
+
MPN cell says N/A are not one part."""
|
|
64
|
+
from .bom_triage import placeholder_mpn
|
|
65
|
+
if placeholder_mpn(s):
|
|
66
|
+
return ""
|
|
67
|
+
pats = (cfg or load_config())["patterns"]
|
|
68
|
+
s = str(s or "").strip().upper()
|
|
69
|
+
for _ in range(4): # suffixes can stack: -TR then (LF)
|
|
70
|
+
before = s
|
|
71
|
+
for p in pats:
|
|
72
|
+
t = p.sub("", s).strip()
|
|
73
|
+
if len(norm_mpn(t)) >= 3: # never strip a part number down to nothing
|
|
74
|
+
s = t
|
|
75
|
+
if s == before:
|
|
76
|
+
break
|
|
77
|
+
return norm_mpn(s)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------- geometry and pinout fingerprints
|
|
81
|
+
def _q(v, step=0.01):
|
|
82
|
+
"""Quantise to step, robust to the 1e-5 mm noise of mil/internal-unit conversion."""
|
|
83
|
+
return math.floor(v / step + 0.501) * step
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _digest(obj):
|
|
87
|
+
return hashlib.sha256(json.dumps(obj, sort_keys=True).encode()).hexdigest()[:16]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pad_tuple(num, x, y, w, h, rot, hole):
|
|
91
|
+
if round(rot) % 180 == 90:
|
|
92
|
+
w, h = h, w
|
|
93
|
+
return (str(num), round(_q(x), 2), round(_q(y), 2), round(_q(w), 2), round(_q(h), 2), round(rot % 90, 1), bool(hole))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _box(xs, ys):
|
|
97
|
+
return [round(_q(v, 0.05), 2) for v in (min(xs), min(ys), max(xs), max(ys))] if xs else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fp_geometry_pcblib(fp, courtyard=AL.MECH15):
|
|
101
|
+
"""courtyard: the layer id the library rules draw courtyards on; Mechanical 15 (ecadlib's own) when nothing is there."""
|
|
102
|
+
pads = [_pad_tuple(r["designator"], AL.to_mm(r["x"]), AL.to_mm(r["y"]), AL.to_mm(r["w"]), AL.to_mm(r["h"]), r["rot"], r["hole"] > 0)
|
|
103
|
+
for r in fp["records"] if r["type"] == "pad"]
|
|
104
|
+
xs, ys = [], []
|
|
105
|
+
shapes = ("track", "arc", "region", "fill")
|
|
106
|
+
if not any(r["layer"] == courtyard for r in fp["records"] if r["type"] in shapes):
|
|
107
|
+
courtyard = AL.MECH15
|
|
108
|
+
for r in fp["records"]:
|
|
109
|
+
if r["layer"] != courtyard:
|
|
110
|
+
continue
|
|
111
|
+
if r["type"] == "track":
|
|
112
|
+
xs += [AL.to_mm(r["x1"]), AL.to_mm(r["x2"])]; ys += [AL.to_mm(r["y1"]), AL.to_mm(r["y2"])]
|
|
113
|
+
elif r["type"] == "arc":
|
|
114
|
+
xs += [AL.to_mm(r["cx"] - r["r"]), AL.to_mm(r["cx"] + r["r"])]; ys += [AL.to_mm(r["cy"] - r["r"]), AL.to_mm(r["cy"] + r["r"])]
|
|
115
|
+
elif r["type"] in ("region", "fill"):
|
|
116
|
+
pts = r.get("pts") or [(r["x1"], r["y1"]), (r["x2"], r["y2"])]
|
|
117
|
+
xs += [AL.to_mm(p[0]) for p in pts]; ys += [AL.to_mm(p[1]) for p in pts]
|
|
118
|
+
return {"pads": sorted(pads), "courtyard": _box(xs, ys)}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fp_geometry_kicad(text):
|
|
122
|
+
t = sexpr.parse(text)
|
|
123
|
+
pads = []
|
|
124
|
+
for p in sexpr.find(t, "pad"):
|
|
125
|
+
layers = (sexpr.first(p, "layers") or ["layers"])[1:]
|
|
126
|
+
if p[2] != "np_thru_hole" and not any(str(x).endswith(".Cu") for x in layers):
|
|
127
|
+
continue # paste-only apertures: no copper, not in the PcbLib
|
|
128
|
+
at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
|
|
129
|
+
rot = float(at[3]) if len(at) > 3 else 0.0
|
|
130
|
+
w, h = float(size[1]), float(size[2] if len(size) > 2 else size[1])
|
|
131
|
+
pads.append(_pad_tuple(p[1], float(at[1]), -float(at[2]), w, h, rot, p[2] in ("thru_hole", "np_thru_hole")))
|
|
132
|
+
cy = sexpr.courtyard_bbox(t)
|
|
133
|
+
return {"pads": sorted(pads), "courtyard": _box([cy[0], cy[2]], [-cy[3], -cy[1]]) if cy else None}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def fp_hash(geom):
|
|
137
|
+
return _digest([list(p) for p in geom["pads"]] + [geom["courtyard"]]) if geom and geom["pads"] else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _pin_name(s):
|
|
141
|
+
return re.sub(r"\\", "", str(s or "")).strip().upper().lstrip("~") or ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sym_pinout(sym):
|
|
145
|
+
"""(designator prefix, sorted (number, name)) of an altium_lib read_schlib entry."""
|
|
146
|
+
pins = sorted({(r["num"], _pin_name(r.get("name"))) for r in sym["records"] if r.get("RECORD") == "2"})
|
|
147
|
+
des = next((r.get("TEXT", "") for r in sym["records"] if r.get("RECORD") == "34"), "")
|
|
148
|
+
return re.match(r"[A-Za-z]*", des).group(0).upper(), pins
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def sym_hash(pinout, min_pins=3):
|
|
152
|
+
prefix, pins = pinout
|
|
153
|
+
return _digest([prefix, [list(p) for p in pins]]) if len(pins) >= min_pins else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------- collecting entries
|
|
157
|
+
class Collector:
|
|
158
|
+
"""Every part / footprint / symbol we know about, from the plan and the libraries."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, cfg, log=print):
|
|
161
|
+
self.cfg, self.log = cfg, log
|
|
162
|
+
self.parts = [] # {name, mpn, key, origin, where, plan_index, status, symbol, footprint}
|
|
163
|
+
self.footprints = [] # {ref, file, origin, hash, used_by}
|
|
164
|
+
self.symbols = [] # {ref, file, origin, hash, pins, used_by}
|
|
165
|
+
self.errors = []
|
|
166
|
+
self._seen_files = set()
|
|
167
|
+
self._cache = {}
|
|
168
|
+
|
|
169
|
+
def _read(self, f, reader):
|
|
170
|
+
key = (str(Path(f).resolve()), reader.__name__)
|
|
171
|
+
if key not in self._cache:
|
|
172
|
+
try:
|
|
173
|
+
self._cache[key] = reader(f)
|
|
174
|
+
except Exception as e: # unreadable library file: report, keep going
|
|
175
|
+
self._cache[key] = None
|
|
176
|
+
self.errors.append(f"{f}: {type(e).__name__}: {e}")
|
|
177
|
+
return self._cache[key]
|
|
178
|
+
|
|
179
|
+
def _fp_from_file(self, f, ref=None):
|
|
180
|
+
"""[(ref, hash)] for a .PcbLib (one per footprint) or .kicad_mod."""
|
|
181
|
+
f = Path(f)
|
|
182
|
+
if f.suffix.lower() == ".kicad_mod":
|
|
183
|
+
g = self._read(f, lambda p: fp_geometry_kicad(Path(p).read_text(encoding="utf-8")))
|
|
184
|
+
return [(ref or f.stem, fp_hash(g))] if g else []
|
|
185
|
+
fps = self._read(f, AL.read_pcblib) or []
|
|
186
|
+
return [(fp["name"], fp_hash(fp_geometry_pcblib(fp, self.cfg.get("courtyard", AL.MECH15)))) for fp in fps if not ref or fp["name"] == ref or len(fps) == 1]
|
|
187
|
+
|
|
188
|
+
def _sym_from_file(self, f, ref=None):
|
|
189
|
+
r = self._read(Path(f), AL.read_schlib)
|
|
190
|
+
syms = r[1] if r else []
|
|
191
|
+
out = []
|
|
192
|
+
for s in syms:
|
|
193
|
+
if ref and s["name"] != ref and len(syms) > 1:
|
|
194
|
+
continue
|
|
195
|
+
po = sym_pinout(s)
|
|
196
|
+
out.append((s["name"], sym_hash(po, self.cfg["symbol_min_pins"]), len(po[1])))
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
def add_plan(self, items, base):
|
|
200
|
+
from .a365_sync import is_a365
|
|
201
|
+
base = Path(base)
|
|
202
|
+
for i, it in enumerate(items):
|
|
203
|
+
par = {p["name"]: p["value"] for p in it.get("parameters") or []}
|
|
204
|
+
mpn = next((par[c] for c in MPN_COLS if par.get(c)), it["name"])
|
|
205
|
+
ws = is_a365(it) # pulled from Altium 365 (ecadlib a365 pull): counts as library
|
|
206
|
+
self.parts.append({"name": it["name"], "mpn": mpn, "key": mpn_key(mpn, self.cfg), "origin": "a365" if ws else "plan",
|
|
207
|
+
"where": "plan (Altium 365)" if ws else "plan",
|
|
208
|
+
"plan_index": i, "status": it.get("status", ""), "symbol": (it.get("symbol") or {}).get("ref") or "",
|
|
209
|
+
"footprint": (it.get("footprint") or {}).get("ref") or "", "source": it.get("source") or {}})
|
|
210
|
+
for kind in ("footprint", "symbol"):
|
|
211
|
+
m = it.get(kind) or {}
|
|
212
|
+
f = m.get("file") and (Path(m["file"]) if Path(m["file"]).is_absolute() else base / m["file"])
|
|
213
|
+
if kind == "footprint" and not (f and f.exists()) and m.get("kicad_mod"):
|
|
214
|
+
f = base / m["kicad_mod"]
|
|
215
|
+
if not (f and f.exists()):
|
|
216
|
+
continue
|
|
217
|
+
self._seen_files.add(str(f.resolve()))
|
|
218
|
+
got = self._fp_from_file(f, m.get("ref")) if kind == "footprint" else self._sym_from_file(f, m.get("ref"))
|
|
219
|
+
for g in got[:1]:
|
|
220
|
+
self._add_model(kind, m.get("ref") or g[0], f, "library" if ws else "plan", g[1], it["name"], it, *(g[2:]))
|
|
221
|
+
|
|
222
|
+
def _add_model(self, kind, ref, f, origin, h, used_by=None, item=None, pins=None):
|
|
223
|
+
lst = self.footprints if kind == "footprint" else self.symbols
|
|
224
|
+
src = ((item or {}).get("source") or {}).get(kind) or ""
|
|
225
|
+
e = next((x for x in lst if x["ref"] == ref and x["hash"] == h and x["origin"] == origin and str(x["file"]) == str(f)), None)
|
|
226
|
+
if not e:
|
|
227
|
+
e = {"ref": ref, "file": str(f), "origin": origin, "hash": h, "used_by": [], "source": src,
|
|
228
|
+
"kicad_mod": ((item or {}).get("footprint") or {}).get("kicad_mod") if kind == "footprint" else None}
|
|
229
|
+
if pins is not None:
|
|
230
|
+
e["pins"] = pins
|
|
231
|
+
lst.append(e)
|
|
232
|
+
if used_by:
|
|
233
|
+
e["used_by"].append(used_by)
|
|
234
|
+
|
|
235
|
+
def add_lib(self, path):
|
|
236
|
+
p = Path(path)
|
|
237
|
+
files = sorted(x for x in p.rglob("*") if x.is_file()) if p.is_dir() else [p]
|
|
238
|
+
for f in files:
|
|
239
|
+
suf = f.suffix.lower()
|
|
240
|
+
if str(f.resolve()) in self._seen_files:
|
|
241
|
+
continue
|
|
242
|
+
if suf in (".pcblib", ".kicad_mod"):
|
|
243
|
+
for ref, h in self._fp_from_file(f):
|
|
244
|
+
self._add_model("footprint", ref, f, "library", h)
|
|
245
|
+
elif suf == ".schlib":
|
|
246
|
+
for ref, h, n in self._sym_from_file(f):
|
|
247
|
+
self._add_model("symbol", ref, f, "library", h, pins=n)
|
|
248
|
+
elif suf == ".dblib":
|
|
249
|
+
self.add_dblib(f)
|
|
250
|
+
elif suf in (".sqlite", ".db", ".xlsx") and str(f.resolve()) not in self._seen_files:
|
|
251
|
+
self.add_table_file(f)
|
|
252
|
+
else:
|
|
253
|
+
continue
|
|
254
|
+
self._seen_files.add(str(f.resolve()))
|
|
255
|
+
|
|
256
|
+
def add_dblib(self, f):
|
|
257
|
+
"""A .DbLib points at its database with a Windows path; look for that file name next to the .DbLib."""
|
|
258
|
+
text = Path(f).read_text(errors="replace", encoding="utf-8")
|
|
259
|
+
m = re.search(r"(?:Data Source|Database|DBQ)=([^;\r\n]+)", text, re.I)
|
|
260
|
+
if not m:
|
|
261
|
+
self.errors.append(f"{f}: no database in ConnectionString"); return
|
|
262
|
+
name = PureWindowsPath(m.group(1).strip().strip('"')).name
|
|
263
|
+
db = next((c for c in (Path(m.group(1).strip()), Path(f).parent / name) if c.exists()), None)
|
|
264
|
+
if not db:
|
|
265
|
+
self.errors.append(f"{f}: database {name} not found next to it"); return
|
|
266
|
+
if str(db.resolve()) not in self._seen_files:
|
|
267
|
+
self.add_table_file(db, via=f)
|
|
268
|
+
self._seen_files.add(str(db.resolve()))
|
|
269
|
+
|
|
270
|
+
def add_table_file(self, f, via=None):
|
|
271
|
+
tables = {}
|
|
272
|
+
try:
|
|
273
|
+
if Path(f).suffix.lower() == ".xlsx":
|
|
274
|
+
import pandas as pd
|
|
275
|
+
tables = {k: v.fillna("").astype(str).to_dict("records") for k, v in pd.read_excel(f, sheet_name=None, dtype=str).items()}
|
|
276
|
+
else:
|
|
277
|
+
with contextlib.closing(sqlite3.connect(Path(f).resolve().as_uri() + "?mode=ro", uri=True)) as con: # closed: Windows locks open files
|
|
278
|
+
con.row_factory = sqlite3.Row
|
|
279
|
+
for (t,) in con.execute("select name from sqlite_master where type='table'"):
|
|
280
|
+
tables[t] = [dict(r) for r in con.execute(f'select * from "{t}"')]
|
|
281
|
+
except Exception as e:
|
|
282
|
+
self.errors.append(f"{f}: {type(e).__name__}: {e}"); return
|
|
283
|
+
for t, rows in tables.items():
|
|
284
|
+
for r in rows:
|
|
285
|
+
pn = str(r.get("Part Number") or "").strip()
|
|
286
|
+
if not pn:
|
|
287
|
+
continue
|
|
288
|
+
mpn = next((str(r[c]) for c in MPN_COLS if str(r.get(c) or "").strip()), pn)
|
|
289
|
+
self.parts.append({"name": pn, "mpn": mpn, "key": mpn_key(mpn, self.cfg), "origin": "dblib",
|
|
290
|
+
"where": f"{via or f} [{t}]", "status": "", "symbol": str(r.get("Library Ref") or ""),
|
|
291
|
+
"footprint": str(r.get("Footprint Ref") or ""), "source": {}})
|
|
292
|
+
|
|
293
|
+
def add_a365(self, f):
|
|
294
|
+
"""check-a365 / a365 pull --state ({name: snapshot}, a365 pull's with `parameters`) or a list of workspace
|
|
295
|
+
component nodes (with parameters)."""
|
|
296
|
+
data = json.loads(Path(f).read_text(encoding="utf-8"))
|
|
297
|
+
nodes = data if isinstance(data, list) else [{"name": k, **(v if isinstance(v, dict) else {})} for k, v in data.items()]
|
|
298
|
+
for n in nodes:
|
|
299
|
+
par = n["parameters"] if isinstance(n.get("parameters"), dict) else \
|
|
300
|
+
{p["name"]: p["value"] for p in ((n.get("details") or {}).get("parameters") or [])}
|
|
301
|
+
mpn = next((par[c] for c in MPN_COLS if par.get(c)), n["name"])
|
|
302
|
+
self.parts.append({"name": n["name"], "mpn": mpn, "key": mpn_key(mpn, self.cfg), "origin": "a365", "where": str(f),
|
|
303
|
+
"status": "released", "symbol": "", "footprint": "", "source": {}})
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------- grouping
|
|
307
|
+
def _score_part(p, keep):
|
|
308
|
+
return (p["name"] in keep, p["origin"] == "a365", p["origin"] == "dblib", p["status"] == "ready",
|
|
309
|
+
str((p.get("source") or {}).get("symbol") or "").startswith("kicad:"), -len(p["mpn"]), -len(p["name"]), p["name"])
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _score_model(m, keep):
|
|
313
|
+
return (m["ref"] in keep, m["origin"] == "library", len(m["used_by"]), str(m.get("source") or "").startswith("kicad:"),
|
|
314
|
+
-len(m["ref"]), m["ref"])
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def find_groups(col, keep=(), ignore=()):
|
|
318
|
+
"""-> list of {kind, key, keeper, members, conflict}. members: dicts with `name` (part / ref)."""
|
|
319
|
+
keep, ignore, groups = set(keep), set(ignore), []
|
|
320
|
+
by = {}
|
|
321
|
+
for p in col.parts:
|
|
322
|
+
if p["key"] and p["name"] not in ignore:
|
|
323
|
+
by.setdefault(p["key"], []).append(p)
|
|
324
|
+
for k, ps in sorted(by.items()):
|
|
325
|
+
names = {p["name"] for p in ps}
|
|
326
|
+
if len(names) < 2:
|
|
327
|
+
continue # same part in the plan, the DbLib and the workspace
|
|
328
|
+
rep = {}
|
|
329
|
+
for p in sorted(ps, key=lambda p: _score_part(p, keep), reverse=True):
|
|
330
|
+
rep.setdefault(p["name"], p) # one member per name, its best origin
|
|
331
|
+
members = sorted(rep.values(), key=lambda p: _score_part(p, keep), reverse=True)
|
|
332
|
+
groups.append({"kind": "mpn", "key": k, "keeper": members[0]["name"], "members": members})
|
|
333
|
+
for kind, lst in (("footprint", col.footprints), ("symbol", col.symbols)):
|
|
334
|
+
by, refs = {}, {}
|
|
335
|
+
for m in lst:
|
|
336
|
+
if m["ref"] in ignore:
|
|
337
|
+
continue
|
|
338
|
+
refs.setdefault(m["ref"], set()).add(m["hash"])
|
|
339
|
+
if m["hash"]:
|
|
340
|
+
by.setdefault(m["hash"], []).append(m)
|
|
341
|
+
for h, ms in sorted(by.items()):
|
|
342
|
+
rep = {}
|
|
343
|
+
for m in ms: # merge same-ref entries (plan + library copy)
|
|
344
|
+
r = rep.setdefault(m["ref"], {**m, "used_by": [], "files": []})
|
|
345
|
+
r["used_by"] += m["used_by"]
|
|
346
|
+
if m["origin"] == "library": # an existing library copy: its file is the one to keep
|
|
347
|
+
r["origin"] = "library"
|
|
348
|
+
r["files"].insert(0, m["file"])
|
|
349
|
+
else:
|
|
350
|
+
r["files"].append(m["file"])
|
|
351
|
+
if len(rep) < 2:
|
|
352
|
+
continue
|
|
353
|
+
members = sorted(rep.values(), key=lambda m: _score_model(m, keep), reverse=True)
|
|
354
|
+
for m in members:
|
|
355
|
+
m["name"] = m["ref"]
|
|
356
|
+
groups.append({"kind": kind, "key": h, "keeper": members[0]["ref"], "members": members})
|
|
357
|
+
for ref, hs in sorted(refs.items()):
|
|
358
|
+
if len(hs - {None}) > 1:
|
|
359
|
+
ver = {} # one member per distinct geometry / pinout
|
|
360
|
+
for m in (m for m in lst if m["ref"] == ref):
|
|
361
|
+
v = ver.setdefault(m["hash"], {**m, "name": f"{ref} #{(m['hash'] or 'unhashed')[:6]}", "used_by": [], "files": []})
|
|
362
|
+
v["used_by"] += m["used_by"]; v["files"].append(m["file"])
|
|
363
|
+
groups.append({"kind": f"{kind}_conflict", "key": ref, "keeper": "", "members": list(ver.values())})
|
|
364
|
+
return groups
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------- merge
|
|
368
|
+
def _rel(f, base):
|
|
369
|
+
try:
|
|
370
|
+
return os.path.relpath(Path(f).resolve(), Path(base).resolve()).replace(os.sep, "/")
|
|
371
|
+
except ValueError: # other drive on Windows
|
|
372
|
+
return Path(f).resolve().as_posix()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def merge(items, groups, base, kinds=("mpn", "footprint", "symbol"), who="ecadlib dedupe"):
|
|
376
|
+
"""Rewrite plan references to each group's keeper. Returns the mapping rows {kind, from, to, file, parts}."""
|
|
377
|
+
by = {i["name"]: i for i in items}
|
|
378
|
+
mapping, today = [], datetime.date.today().isoformat()
|
|
379
|
+
for g in groups:
|
|
380
|
+
if g["kind"] not in kinds:
|
|
381
|
+
continue
|
|
382
|
+
keeper = next(m for m in g["members"] if m["name"] == g["keeper"])
|
|
383
|
+
if g["kind"] == "mpn":
|
|
384
|
+
for m in g["members"][1:]:
|
|
385
|
+
it = by.get(m["name"]) # the plan item of that name, whatever origin ranked first
|
|
386
|
+
if it and it.get("a365") and it.get("status") == "existing":
|
|
387
|
+
it = None # a workspace component: reported, never rewritten
|
|
388
|
+
mapping.append({"kind": "part", "from": m["name"], "to": keeper["name"], "file": "",
|
|
389
|
+
"parts": m["name"] if it else "", "note": "" if it else f"{m['origin']} entry: not changed"})
|
|
390
|
+
if it and it["name"] != keeper["name"]:
|
|
391
|
+
it["duplicate_of"], it["status"] = keeper["name"], "duplicate"
|
|
392
|
+
it["dedupe"] = {"by": who, "date": today, "mpn_key": g["key"], "keeper_origin": keeper["origin"]}
|
|
393
|
+
continue
|
|
394
|
+
kind = g["kind"]
|
|
395
|
+
ext = ".pcblib" if kind == "footprint" else ".schlib" # a plan item needs the Altium file, not a .kicad_mod
|
|
396
|
+
kfile = next((f for f in keeper.get("files") or [keeper["file"]] if f.lower().endswith(ext)), None)
|
|
397
|
+
kmod = next((i["footprint"].get("kicad_mod") for i in items if kind == "footprint" and kfile
|
|
398
|
+
and (i.get("footprint") or {}).get("ref") == keeper["ref"] and i["footprint"].get("kicad_mod")
|
|
399
|
+
and Path(_rel(kfile, base)) == Path(i["footprint"].get("file") or "")), None)
|
|
400
|
+
for m in g["members"][1:]:
|
|
401
|
+
if not kfile:
|
|
402
|
+
mapping.append({"kind": kind, "from": m["ref"], "to": keeper["ref"], "file": "", "parts": "",
|
|
403
|
+
"note": f"keeper has no {'.PcbLib' if kind == 'footprint' else '.SchLib'}: not changed"})
|
|
404
|
+
continue
|
|
405
|
+
changed = []
|
|
406
|
+
for name in m["used_by"]:
|
|
407
|
+
it = by.get(name)
|
|
408
|
+
if not it or (it.get(kind) or {}).get("ref") != m["ref"]:
|
|
409
|
+
continue
|
|
410
|
+
old = dict(it[kind])
|
|
411
|
+
new = {"ref": keeper["ref"], "file": _rel(kfile, base)}
|
|
412
|
+
if kmod:
|
|
413
|
+
new["kicad_mod"] = kmod
|
|
414
|
+
it[kind] = new
|
|
415
|
+
it.setdefault("merged_from", {})[kind] = {**old, "by": who, "date": today}
|
|
416
|
+
changed.append(name)
|
|
417
|
+
mapping.append({"kind": kind, "from": m["ref"], "to": keeper["ref"], "file": _rel(kfile, base),
|
|
418
|
+
"parts": ", ".join(changed), "note": "" if changed else "library only: not changed"})
|
|
419
|
+
return mapping
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def remap_bom(bom, mapping, out):
|
|
423
|
+
"""Copy of the BOM with LibRef / Footprint cells mapped to the keepers (other columns untouched)."""
|
|
424
|
+
from . import bom_triage as T
|
|
425
|
+
import pandas as pd
|
|
426
|
+
p = Path(bom)
|
|
427
|
+
raw = (T._read_excel(p) if p.suffix.lower() in (".xlsx", ".xlsm", ".xls") else T._read_csv(p)).fillna("") # the BOM sheet,
|
|
428
|
+
# as read_bom picks it (not a cover sheet)
|
|
429
|
+
hdr = max(range(min(10, len(raw))), key=lambda i: len(T.map_headers(list(raw.iloc[i]))))
|
|
430
|
+
headers = [str(h).strip() for h in raw.iloc[hdr]]
|
|
431
|
+
cols = T.map_headers(headers)
|
|
432
|
+
m = {k: {r["from"]: r["to"] for r in mapping if r["kind"] == k} for k in ("symbol", "footprint")}
|
|
433
|
+
n = 0
|
|
434
|
+
for field, kind in (("libref", "symbol"), ("footprint", "footprint")):
|
|
435
|
+
if field not in cols:
|
|
436
|
+
continue
|
|
437
|
+
j = headers.index(cols[field])
|
|
438
|
+
for i in range(hdr + 1, len(raw)):
|
|
439
|
+
v = str(raw.iat[i, j]).strip()
|
|
440
|
+
lib, _, ref = v.rpartition(":")
|
|
441
|
+
if ref in m[kind]:
|
|
442
|
+
raw.iat[i, j] = (f"{lib}:" if lib else "") + m[kind][ref]
|
|
443
|
+
n += 1
|
|
444
|
+
out = Path(out)
|
|
445
|
+
SS.frame(raw).to_csv(out, header=False, index=False)
|
|
446
|
+
return out, n
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---------------------------------------------------------------- reports & CLI
|
|
450
|
+
def report_rows(groups):
|
|
451
|
+
rows = []
|
|
452
|
+
for gi, g in enumerate(groups, 1):
|
|
453
|
+
for m in g["members"]:
|
|
454
|
+
rows.append({"group": gi, "kind": g["kind"], "key": g["key"], "member": m["name"],
|
|
455
|
+
"keeper": "yes" if g["keeper"] and m["name"] == g["keeper"] else "",
|
|
456
|
+
"origin": m["origin"], "where": m.get("where") or "; ".join(m.get("files") or [m.get("file", "")]),
|
|
457
|
+
"mpn": m.get("mpn", ""), "used_by": ", ".join(m.get("used_by") or []), "status": m.get("status", "")})
|
|
458
|
+
return rows
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def write_reports(groups, mapping, stem, plan, errors=()):
|
|
462
|
+
stem = Path(stem)
|
|
463
|
+
stem.parent.mkdir(parents=True, exist_ok=True)
|
|
464
|
+
rows = report_rows(groups)
|
|
465
|
+
fields = ["group", "kind", "key", "member", "keeper", "origin", "where", "mpn", "used_by", "status"]
|
|
466
|
+
with open(stem.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
|
|
467
|
+
w = SS.DictWriter(f, fieldnames=fields); w.writeheader(); w.writerows(rows)
|
|
468
|
+
mp = stem.parent / f"{stem.name}_map.csv"
|
|
469
|
+
with open(mp, "w", newline="", encoding="utf-8") as f:
|
|
470
|
+
w = SS.DictWriter(f, fieldnames=["kind", "from", "to", "file", "parts", "note"]); w.writeheader(); w.writerows(mapping)
|
|
471
|
+
count = lambda k: sum(g["kind"] == k for g in groups)
|
|
472
|
+
md = [f"# Duplicates - `{plan}`", "",
|
|
473
|
+
f"{count('mpn')} part groups (same MPN), {count('footprint')} footprint groups (same geometry), "
|
|
474
|
+
f"{count('symbol')} symbol groups (same pinout); {count('footprint_conflict') + count('symbol_conflict')} name conflicts.", ""]
|
|
475
|
+
for gi, g in enumerate(groups, 1):
|
|
476
|
+
title = {"mpn": f"MPN `{g['key']}`", "footprint": "footprint geometry", "symbol": "symbol pinout",
|
|
477
|
+
"footprint_conflict": f"footprint `{g['key']}`: same name, different geometry",
|
|
478
|
+
"symbol_conflict": f"symbol `{g['key']}`: same name, different pinout"}[g["kind"]]
|
|
479
|
+
md += [f"## {gi}. {title}" + (f" -> keep **{g['keeper']}**" if g["keeper"] else ""), ""]
|
|
480
|
+
for m in g["members"]:
|
|
481
|
+
where = m.get("where") or ", ".join(m.get("files") or [m.get("file", "")])
|
|
482
|
+
md.append(f"- {'**keep** ' if g['keeper'] and m['name'] == g['keeper'] else ''}`{m['name']}` ({m['origin']}"
|
|
483
|
+
+ (f", MPN {m['mpn']}" if m.get("mpn") and m["mpn"] != m["name"] else "")
|
|
484
|
+
+ (f", used by {', '.join(m['used_by'])}" if m.get("used_by") else "") + f"): {where}")
|
|
485
|
+
md.append("")
|
|
486
|
+
if mapping:
|
|
487
|
+
md += ["## Mapping (written to the plan with --merge; library files are never changed)", "",
|
|
488
|
+
"| Kind | From | To | Plan items | Note |", "|---|---|---|---|---|"]
|
|
489
|
+
md += [f"| {r['kind']} | {r['from']} | {r['to']} | {r['parts'] or '-'} | {r['note'] or '-'} |" for r in mapping]
|
|
490
|
+
if errors:
|
|
491
|
+
md += ["", "## Could not read", ""] + [f"- {e}" for e in errors]
|
|
492
|
+
stem.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
|
|
493
|
+
return stem, mp
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def backup(plan):
|
|
497
|
+
"""Copy the plan before --merge rewrites it. The first copy is plan.json.pre-dedupe and is never overwritten (a
|
|
498
|
+
second merge used to replace the original with the already merged plan); later ones get a UTC timestamp."""
|
|
499
|
+
plan = Path(plan)
|
|
500
|
+
first = plan.with_name(plan.name + ".pre-dedupe")
|
|
501
|
+
dst = first
|
|
502
|
+
if first.exists():
|
|
503
|
+
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
504
|
+
dst, n = plan.with_name(f"{first.name}-{stamp}"), 1
|
|
505
|
+
while dst.exists():
|
|
506
|
+
n += 1
|
|
507
|
+
dst = plan.with_name(f"{first.name}-{stamp}-{n}")
|
|
508
|
+
shutil.copyfile(plan, dst)
|
|
509
|
+
return dst
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def run(plan, libs=(), a365=None, config=None, keep=(), do_merge=False, kinds=("mpn", "footprint", "symbol"), out=None,
|
|
513
|
+
bom=None, who="ecadlib dedupe", log=print):
|
|
514
|
+
plan = Path(plan)
|
|
515
|
+
base = plan.parent
|
|
516
|
+
cfg = load_config(config, base)
|
|
517
|
+
from . import rules as RU # house rules may draw the courtyard on another mechanical layer
|
|
518
|
+
cfg["courtyard"] = RU.layer_id(RU.load(None, start=base)["layers"]["courtyard"])
|
|
519
|
+
items = json.loads(plan.read_text(encoding="utf-8"))
|
|
520
|
+
col = Collector(cfg, log)
|
|
521
|
+
col.add_plan(items, base)
|
|
522
|
+
for lib in libs:
|
|
523
|
+
if not Path(lib).exists():
|
|
524
|
+
raise FileNotFoundError(f"--lib {lib}: not found")
|
|
525
|
+
col.add_lib(lib)
|
|
526
|
+
a365 = a365 or (base / "a365_state.json" if (base / "a365_state.json").exists() else None)
|
|
527
|
+
if a365:
|
|
528
|
+
col.add_a365(a365)
|
|
529
|
+
groups = find_groups(col, list(keep) + cfg["keep"], cfg["ignore"])
|
|
530
|
+
if do_merge:
|
|
531
|
+
backup(plan)
|
|
532
|
+
mapping = merge(items, groups, base, kinds, who)
|
|
533
|
+
write_text_atomic(plan, json.dumps(items, indent=2))
|
|
534
|
+
else:
|
|
535
|
+
mapping = merge(json.loads(json.dumps(items)), groups, base, kinds, who) # dry run on a copy: what would change
|
|
536
|
+
stem, mp = write_reports(groups, mapping, out or base / "dedupe", plan, col.errors)
|
|
537
|
+
bom_out = remap_bom(bom, mapping, Path(stem).parent / f"{Path(bom).stem}_deduped.csv") if bom else None
|
|
538
|
+
return {"groups": groups, "mapping": mapping, "report": stem.with_suffix(".md"), "map": mp, "bom": bom_out,
|
|
539
|
+
"errors": col.errors, "items": items}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def main(argv=None):
|
|
543
|
+
ap = argparse.ArgumentParser(description="Find duplicate parts, footprints and symbols across a plan and existing libraries")
|
|
544
|
+
ap.add_argument("plan")
|
|
545
|
+
ap.add_argument("--lib", action="append", default=[], help="library file or folder (.SchLib .PcbLib .kicad_mod .DbLib .sqlite .xlsx); repeatable")
|
|
546
|
+
ap.add_argument("--a365", help="check-a365 --state file or a JSON list of workspace components (default <plan dir>/a365_state.json)")
|
|
547
|
+
ap.add_argument("--config", help="dedupe config JSON (default <plan dir>/dedupe.json)")
|
|
548
|
+
ap.add_argument("--keep", action="append", default=[], help="force this part / footprint / symbol name as keeper (repeatable)")
|
|
549
|
+
ap.add_argument("--merge", action="store_true", help="rewrite the plan's references to the keepers")
|
|
550
|
+
ap.add_argument("--kinds", default="mpn,footprint,symbol", help="what --merge rewrites (default %(default)s)")
|
|
551
|
+
ap.add_argument("--bom", help="also write a copy of this BOM with LibRef / Footprint mapped to the keepers")
|
|
552
|
+
ap.add_argument("-o", "--out", help="report stem (default <plan dir>/dedupe)")
|
|
553
|
+
a = ap.parse_args(argv)
|
|
554
|
+
from .approvals import cli_plan_lock
|
|
555
|
+
try:
|
|
556
|
+
with cli_plan_lock(a.plan) if a.merge else contextlib.nullcontext(): # the agent may be rewriting this plan
|
|
557
|
+
r = run(a.plan, a.lib, a.a365, a.config, a.keep, a.merge, tuple(k for k in a.kinds.split(",") if k), a.out, a.bom)
|
|
558
|
+
except (OSError, ValueError, json.JSONDecodeError) as e:
|
|
559
|
+
print(f"dedupe: {type(e).__name__}: {e}", file=sys.stderr)
|
|
560
|
+
return 2
|
|
561
|
+
for g in r["groups"]:
|
|
562
|
+
print(f" {g['kind']:<18} keep {g['keeper'] or '-':<28} " + ", ".join(m["name"] for m in g["members"] if m["name"] != g["keeper"]))
|
|
563
|
+
for e in r["errors"]:
|
|
564
|
+
print(f" unreadable: {e}")
|
|
565
|
+
acted = [m for m in r["mapping"] if m["parts"]]
|
|
566
|
+
print(f"{len(r['groups'])} groups; {len(acted)} plan rewrites {'applied' if a.merge else 'proposed (use --merge)'}"
|
|
567
|
+
f" -> {r['report']}, {r['map']}" + (f", BOM {r['bom'][0]} ({r['bom'][1]} cells)" if r["bom"] else ""))
|
|
568
|
+
return 1 if r["groups"] else 0
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
if __name__ == "__main__":
|
|
572
|
+
sys.exit(main())
|