ecadlib 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/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())