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/rules_infer.py ADDED
@@ -0,0 +1,1205 @@
1
+ """Work out a library's rules from what the user already has, and write ecadlib.toml with minimal setup.
2
+
3
+ ecadlib init [--from DIR_OR_FILE ...] [--a365 FILE] [--yes] [--force] [-o ecadlib.toml] [--json]
4
+
5
+ Scans (recursively for folders):
6
+ .SchLib component names, parameters actually used and how often, designators, footprint links
7
+ .PcbLib footprint names, which mechanical layers hold courtyard / assembly / 3D bodies, pad sizes
8
+ .DbLib/.SVNDbLib field mappings (= parameter names) and the table (sheet, SQLite) rows when the data file is
9
+ reachable next to it; tables are categories
10
+ parts tables .xlsx/.csv/.sqlite with a Library Ref / Part Number column (a DbLib's data on its own)
11
+ Altium 365 lists a JSON list of components {name, folder, parameters{...}, symbol, footprint} (a365_sync), the
12
+ Nexar node shape, or an `upstream check-a365 --state` file (names only)
13
+ .kicad_sym/.pretty KiCad symbol names and properties, footprint names and pads
14
+ BOMs .csv/.xlsx column headers (what the BOM reader would map, and where it had to guess)
15
+
16
+ and proposes each setting with its evidence and a confidence. Names are pattern-mined: each name is split into
17
+ tokens that are classified as package (0603, C0402), value (10K, 4K7, 100N, 100nF), tolerance (1%), voltage
18
+ (16V), dielectric (X7R), power, the part's own MPN or manufacturer, a category word (RES/CAP) or a literal;
19
+ the commonest template per category wins (a template that is the same with fewer fields counts for it).
20
+ Footprints: IPC-7351 names (RESC1608X55N; the last letter is the density), KiCad names, or a mined template.
21
+ Density comes from IPC names, ecadlib's own tags and, for chip parts 0603 and up, the toe-to-toe pad length
22
+ compared with footprint_gen at L/N/M. Where the evidence is split, `init` asks one short question (with --yes it
23
+ takes the best guess). An existing ecadlib.toml is never replaced without confirmation: the diff is shown."""
24
+ import argparse, contextlib, json, re, sqlite3, sys
25
+ from collections import Counter, defaultdict
26
+ from pathlib import Path
27
+
28
+ from . import rules as RU
29
+
30
+ IMPERIAL = ("01005", "0201", "0402", "0603", "0805", "1206", "1210", "1812", "2010", "2512")
31
+ METRIC = ("0402", "0603", "1005", "1608", "2012", "3216", "3225", "4532", "5025", "6332")
32
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", "http_cache", ".venv", "venv", ".svn"}
33
+ MAX_FILES = 3000
34
+ PARAM_SYN = {
35
+ "Manufacturer Part Number": ("mpn", "manufacturerpartnumber", "mfrpartnumber", "mfrpn", "mfgpartnumber", "mfrpartno",
36
+ "manufacturerpn", "mfrpart", "manufacturerpart", "mfgpn", "mfrno", "manufacturerpartno",
37
+ "partnumbermfr", "mfrpartnum", "manufacturerpartnum"),
38
+ "Manufacturer": ("manufacturer", "mfr", "mfg", "manufacturername", "mfrname", "maker", "mfgname"),
39
+ "Supplier 1": ("supplier1", "supplier", "vendor", "vendor1", "distributor", "distributor1"),
40
+ "Supplier Part Number 1": ("supplierpartnumber1", "supplierpartnumber", "supplierpn", "supplierpn1", "spn", "vendorpn",
41
+ "vendorpartnumber", "distributorpn", "distributorpartnumber", "supplierpartno", "vendorpartno"),
42
+ "Supplier 2": ("supplier2", "vendor2", "distributor2"),
43
+ "Supplier Part Number 2": ("supplierpartnumber2", "supplierpn2", "vendorpn2", "vendorpartnumber2", "distributorpn2"),
44
+ "ComponentLink2URL": ("componentlink2url", "datasheet", "datasheeturl", "datasheetlink", "helpurl", "datasheetlocation"),
45
+ "Value": ("value", "val"),
46
+ }
47
+ NOT_PARAMS = {"comment", "description", "designator", "libraryref", "librarypath", "footprintref", "footprintpath",
48
+ "partnumber", "footprint", "reference", "kidescription", "kikeywords", "kifpfilters", "componentlink1description",
49
+ "componentlink2description", "hiddennetname", "pcb3dref", "pcb3dpath"}
50
+ FOLDER_WORDS = [("Resistors", r"resist|^res$|^r$"), ("Capacitors", r"capac|^caps?$|^c$"), ("Inductors", r"induct|^ind$|^l$|ferrite|bead"),
51
+ ("Diodes", r"diode|^led|zener|tvs"), ("Transistors", r"transist|mosfet|fet$|bjt|^q$"),
52
+ ("Crystals", r"crystal|oscill|xtal|resonat"), ("Connectors", r"connect|header|^conn|^j$"),
53
+ ("ICs", r"^ics?$|integrated|semi|mcu|micro|regulat|interface|logic|analog|power|memory|^u$|amplif|sensor")]
54
+
55
+
56
+ def _norm(s):
57
+ return re.sub(r"[^a-z0-9]", "", str(s).lower())
58
+
59
+
60
+ def canonical_of(house):
61
+ n = _norm(house)
62
+ return next((c for c, syn in PARAM_SYN.items() if n in syn), None)
63
+
64
+
65
+ def category_of(*hints):
66
+ """Category from designator ('R?', 'R'), a folder or table name, a name prefix (RES_...)."""
67
+ for h in hints:
68
+ h = str(h or "").strip()
69
+ if not h:
70
+ continue
71
+ m = re.match(r"^([A-Z]{1,3})\??\d*$", h)
72
+ if m and m.group(1) in RU.REF_CLASS:
73
+ return RU.REF_CLASS[m.group(1)]
74
+ for seg in reversed([x for x in re.split(r"[/\\]", h) if x]):
75
+ s = re.sub(r"\$$", "", seg).strip().lower()
76
+ for cat, rx in FOLDER_WORDS:
77
+ if re.search(rx, s):
78
+ return cat
79
+ return None
80
+
81
+
82
+ def name_category(name):
83
+ m = re.match(r"^(RES|CAP|IND|R|C|L)(?=[_\-\s]|\d{4}[_\-])", str(name), re.I)
84
+ return {"res": "Resistors", "r": "Resistors", "cap": "Capacitors", "c": "Capacitors", "ind": "Inductors",
85
+ "l": "Inductors"}[m.group(1).lower()] if m else None
86
+
87
+
88
+ class Library:
89
+ """Everything the scanners found, in one shape."""
90
+ def __init__(self):
91
+ self.components, self.footprints, self.boms, self.files, self.warnings = [], [], [], Counter(), []
92
+ self.sym_pins = [] # per SchLib symbol: (name, [(x, y, length) of each pin], DXP units)
93
+ self.pin_names = [] # every pin name of every symbol (house_style: active-low notation)
94
+
95
+ def add_component(self, name, origin, params=None, category=None, folder="", symbol="", footprint="", designator="",
96
+ description=""):
97
+ params = {str(k): str(v) for k, v in (params or {}).items() if str(k).strip()}
98
+ cat = (category or category_of(designator) or category_of(folder) or name_category(name)
99
+ or category_of(params.get("Category", ""), params.get("Type", "")) or "Misc")
100
+ self.components.append({"name": str(name), "category": cat, "folder": folder, "params": params, "symbol": symbol,
101
+ "footprint": footprint, "designator": designator, "description": description, "origin": origin})
102
+
103
+ def kinds(self):
104
+ return dict(self.files)
105
+
106
+
107
+ # ------------------------------------------------------------------ scanners
108
+ def scan(paths, a365=None):
109
+ """Scan files and folders -> Library. a365: extra Altium 365 component lists (paths, or lists of dicts)."""
110
+ lib = Library()
111
+ files = []
112
+ for p in paths or []:
113
+ p = Path(p).expanduser()
114
+ if p.is_dir():
115
+ found = []
116
+ for f in sorted(p.rglob("*")):
117
+ if len(found) >= MAX_FILES:
118
+ lib.warnings.append(f"stopped after {MAX_FILES} files under {p}")
119
+ break
120
+ if f.is_file() and not any(part in SKIP_DIRS for part in f.relative_to(p).parts[:-1]):
121
+ found.append(f)
122
+ own = _own_output_dirs(p, found)
123
+ for d in sorted(own):
124
+ lib.warnings.append(f"skipped {d.as_posix()}: ecadlib's own output (it would teach ecadlib its own defaults)")
125
+ files += [(f, False) for f in found if not any(d in f.parents for d in own)]
126
+ elif p.is_file():
127
+ files.append((p, True))
128
+ else:
129
+ lib.warnings.append(f"{p}: not found")
130
+ for x in a365 or []:
131
+ if isinstance(x, (list, dict)):
132
+ scan_a365_data(lib, x, "Altium 365 list")
133
+ else:
134
+ files.append((Path(x), True))
135
+ for f, explicit in files:
136
+ try:
137
+ scan_file(lib, f, explicit)
138
+ except Exception as e: # one unreadable file must not stop the scan
139
+ lib.warnings.append(f"{f}: {type(e).__name__}: {e}")
140
+ return lib
141
+
142
+
143
+ OWN_OUTPUTS = {"librarian_state.json", "kicad_export.json", "kicad_dblib.json", "release_check.csv"}
144
+
145
+
146
+ def _own_output_dirs(root, files):
147
+ """Folders under root (not root itself) that ecadlib wrote: a librarian / kicad-export output folder, or a whole
148
+ workspace (uploads/ next to altium_models/, e.g. `ecadlib demo`). Scanning them would learn ecadlib's own defaults
149
+ (and its report columns as BOM headers)."""
150
+ dirs = set()
151
+ for f in files:
152
+ if f.name in OWN_OUTPUTS:
153
+ dirs.add(f.parent)
154
+ for d in f.parents:
155
+ if d == root or root not in d.parents:
156
+ break
157
+ if (d / "uploads").is_dir() and (d / "altium_models").is_dir():
158
+ dirs.add(d)
159
+ return {d for d in dirs if d != root and not any(o in d.parents for o in dirs)}
160
+
161
+
162
+ def scan_file(lib, f, explicit=False):
163
+ suf = f.suffix.lower()
164
+ if suf == ".schlib":
165
+ scan_schlib(lib, f)
166
+ elif suf == ".pcblib":
167
+ scan_pcblib(lib, f)
168
+ elif suf in (".dblib", ".svndblib"):
169
+ scan_dblib(lib, f)
170
+ elif suf == ".kicad_sym":
171
+ scan_kicad_sym(lib, f)
172
+ elif suf == ".kicad_mod":
173
+ scan_kicad_mod(lib, f)
174
+ elif suf == ".json" and (explicit or re.search(r"a365|altium.?365|components|workspace", f.name, re.I)) \
175
+ and not re.search(r"plan|report|manifest|package", f.name, re.I):
176
+ scan_a365_data(lib, json.loads(f.read_text(encoding="utf-8")), str(f), explicit)
177
+ elif suf in (".csv", ".xlsx", ".xlsm", ".tsv") or (suf in (".sqlite", ".db") and explicit):
178
+ scan_table(lib, f, explicit)
179
+
180
+
181
+ def scan_schlib(lib, f):
182
+ from . import altium_lib as AL
183
+ _, syms = AL.read_schlib(f)
184
+ lib.files["SchLib"] += 1
185
+ for s in syms:
186
+ recs, params, desig, fp, desc = s["records"], {}, "", "", ""
187
+ for r in recs:
188
+ rt = r.get("RECORD")
189
+ if rt == "1":
190
+ desc = r.get("COMPONENTDESCRIPTION", "")
191
+ elif rt == "34":
192
+ desig = r.get("TEXT", "")
193
+ elif rt == "41" and r.get("NAME") and r.get("NAME") not in ("Comment", "HiddenNetName") and "OWNERINDEX" not in r:
194
+ params[r["NAME"]] = r.get("TEXT", "")
195
+ elif rt == "45" and r.get("MODELTYPE", "").upper() == "PCBLIB" and not fp:
196
+ fp = r.get("MODELNAME", "")
197
+ lib.add_component(s["name"], f"SchLib {f.name}", params, None, f.stem if category_of(f.stem) else "", s["name"], fp,
198
+ desig, desc)
199
+ pins = [(r["x"], r["y"], r["length"]) for r in recs if r.get("RECORD") == "2" and not r.get("mode")]
200
+ lib.pin_names += [r["name"] for r in recs if r.get("RECORD") == "2" and not r.get("mode") and r.get("name")]
201
+ if pins:
202
+ lib.sym_pins.append((s["name"], pins))
203
+
204
+
205
+ def _bbox(pts):
206
+ xs, ys = [p[0] for p in pts], [p[1] for p in pts]
207
+ return (min(xs), min(ys), max(xs), max(ys)) if xs else None
208
+
209
+
210
+ def scan_pcblib(lib, f):
211
+ from . import altium_lib as AL
212
+ lib.files["PcbLib"] += 1
213
+ for fp in AL.read_pcblib(f):
214
+ mm = AL.to_mm
215
+ pads, graphics, texts, bodies = [], defaultdict(list), [], []
216
+ for r in fp["records"]:
217
+ t = r["type"]
218
+ if t == "pad":
219
+ w, h = mm(r["w"]), mm(r["h"])
220
+ if round(r["rot"]) % 180 == 90:
221
+ w, h = h, w
222
+ pads.append((r["designator"], mm(r["x"]), mm(r["y"]), w, h))
223
+ elif t == "track":
224
+ graphics[r["layer"]] += [(mm(r["x1"]), mm(r["y1"])), (mm(r["x2"]), mm(r["y2"]))]
225
+ elif t == "arc":
226
+ c, rr = (mm(r["cx"]), mm(r["cy"])), mm(r["r"])
227
+ graphics[r["layer"]] += [(c[0] - rr, c[1] - rr), (c[0] + rr, c[1] + rr)]
228
+ elif t == "fill":
229
+ graphics[r["layer"]] += [(mm(r["x1"]), mm(r["y1"])), (mm(r["x2"]), mm(r["y2"]))]
230
+ elif t == "region":
231
+ graphics[r["layer"]] += [(mm(x), mm(y)) for x, y in r["pts"]]
232
+ elif t == "body":
233
+ bp = AL.parse_params(r.get("params", ""))
234
+ v7 = bp.get("V7_LAYER", "")
235
+ m = re.match(r"MECHANICAL(\d+)", v7)
236
+ bodies.append(56 + int(m.group(1)) if m and int(m.group(1)) <= 16 else r["layer"]) # 17..32: reader's id
237
+ elif t == "text":
238
+ texts.append((r["text"], r["layer"]))
239
+ lib.footprints.append({"name": fp["name"], "origin": f"PcbLib {f.name}", "kind": "altium", "pads": pads,
240
+ "graphics": {k: _bbox(v) for k, v in graphics.items()}, "texts": texts, "bodies": bodies,
241
+ "description": fp["params"].get("DESCRIPTION", "")})
242
+
243
+
244
+ def scan_kicad_sym(lib, f):
245
+ from . import sexpr
246
+ t = sexpr.parse(f.read_text(errors="replace", encoding="utf-8"))
247
+ lib.files["kicad_sym"] += 1
248
+ for s in sexpr.find(t, "symbol"):
249
+ props = {p[1]: p[2] for p in sexpr.find(s, "property") if len(p) > 2}
250
+ params = {k: v for k, v in props.items() if k not in ("Reference", "Value", "Footprint", "Datasheet", "Description")
251
+ and not k.startswith("ki_")}
252
+ if props.get("Value") and str(props.get("Value")) != str(s[1]):
253
+ params["Value"] = props["Value"]
254
+ lib.add_component(s[1], f"KiCad {f.name}", params, None, "", s[1], props.get("Footprint", "").split(":")[-1],
255
+ props.get("Reference", ""), props.get("Description", ""))
256
+ lib.pin_names += [str(nm[1]) for pin in _walk(s, "pin") for nm in sexpr.find(pin, "name") if len(nm) > 1 and nm[1] != "~"]
257
+
258
+
259
+ def _walk(node, head):
260
+ """Every list under node (any depth) whose first element == head: a KiCad symbol's pins sit in its unit symbols."""
261
+ for c in node:
262
+ if isinstance(c, list) and c:
263
+ if c[0] == head:
264
+ yield c
265
+ else:
266
+ yield from _walk(c, head)
267
+
268
+
269
+ def scan_kicad_mod(lib, f):
270
+ from . import sexpr
271
+ text = f.read_text(errors="replace", encoding="utf-8")
272
+ t = sexpr.parse(text)
273
+ lib.files["kicad_mod"] += 1
274
+ pads = [(p["number"], p["x"], -p["y"], p["w"], p["h"]) for p in sexpr.pads(t) if "F.Cu" in p["layers"] or "*.Cu" in p["layers"]]
275
+ tags = " ".join(str(x) for x in (sexpr.first(t, "tags") or [])[1:]) + " " + " ".join(str(x) for x in (sexpr.first(t, "descr") or [])[1:])
276
+ lib.footprints.append({"name": str(t[1]).split(":")[-1], "origin": f"KiCad {f.parent.name}/{f.name}", "kind": "kicad",
277
+ "pads": pads, "graphics": {}, "texts": [], "bodies": [], "description": tags})
278
+
279
+
280
+ def scan_a365_data(lib, data, origin, explicit=True):
281
+ """A list of component dicts, Nexar component nodes, or a check-a365 --state file ({name: snapshot})."""
282
+ if isinstance(data, dict) and isinstance(data.get("components"), list):
283
+ data = data["components"]
284
+ if isinstance(data, dict):
285
+ if not all(isinstance(v, dict) and ("revision" in v or "symbols" in v) for v in data.values()):
286
+ return
287
+ lib.files["Altium 365 (state, names only)"] += 1
288
+ for name in data:
289
+ lib.add_component(name, f"A365 {Path(origin).name}")
290
+ return
291
+ if not isinstance(data, list) or not data or not all(isinstance(x, dict) and "name" in x for x in data):
292
+ return
293
+ if not explicit and not any(("parameters" in x or "folder" in x or "details" in x) for x in data):
294
+ return
295
+ lib.files["Altium 365 list"] += 1
296
+ for x in data:
297
+ params = x.get("parameters")
298
+ d = x.get("details") or {}
299
+ if isinstance(params, list):
300
+ params = {p.get("name"): p.get("value", "") for p in params if isinstance(p, dict)}
301
+ elif params is None:
302
+ params = {p["name"]: p.get("value", "") for p in d.get("parameters") or [] if isinstance(p, dict) and p.get("name")}
303
+ folder = x.get("folder") or ""
304
+ if isinstance(folder, dict):
305
+ folder = folder.get("path") or folder.get("name") or ""
306
+ sym = x.get("symbol") or ((d.get("symbols") or [{}])[0] or {}).get("name", "")
307
+ fp = x.get("footprint") or ((d.get("footprints") or [{}])[0] or {}).get("name", "")
308
+ lib.add_component(x["name"], f"A365 {Path(str(origin)).name}", params, None, folder.replace("\\", "/"),
309
+ sym if isinstance(sym, str) else "", fp if isinstance(fp, str) else "", x.get("designator", ""),
310
+ x.get("description") or x.get("comment") or "")
311
+
312
+
313
+ def _key_col(cols):
314
+ return next((c for c in cols if _norm(c) in ("partnumber", "partid", "partno", "internalpartnumber", "ipn")), None)
315
+
316
+
317
+ def add_table_rows(lib, table, rows, origin):
318
+ """Rows of a DbLib table (dicts) -> components."""
319
+ if not rows:
320
+ return
321
+ cols = list(rows[0])
322
+ key = _key_col(cols) or next((c for c in cols if _norm(c) == "libraryref"), cols[0])
323
+ for r in rows:
324
+ name = str(r.get(key) or "").strip()
325
+ if not name or name.lower() == "nan":
326
+ continue
327
+ params = {c: ("" if str(v) == "nan" else v) for c, v in r.items() if c != key and _norm(c) not in NOT_PARAMS}
328
+ lib.add_component(name, origin, params, category_of(table) or category_of(r.get("Library Ref", "")), str(table).rstrip("$"),
329
+ str(r.get("Library Ref") or ""), str(r.get("Footprint Ref") or ""), "", str(r.get("Description") or ""))
330
+
331
+
332
+ def read_table_file(f):
333
+ """{table: [row dicts]} from .xlsx / .csv / .sqlite / .db."""
334
+ import pandas as pd
335
+ suf = f.suffix.lower()
336
+ if suf in (".sqlite", ".db", ".sqlite3"):
337
+ out = {}
338
+ with contextlib.closing(sqlite3.connect(Path(f).resolve().as_uri() + "?mode=ro", uri=True)) as con: # closed: Windows locks open files
339
+ for (t,) in con.execute("SELECT name FROM sqlite_master WHERE type='table'"):
340
+ cur = con.execute(f'SELECT * FROM "{t}"')
341
+ cols = [c[0] for c in cur.description]
342
+ out[t] = [dict(zip(cols, (str(v) if v is not None else "" for v in row))) for row in cur.fetchall()]
343
+ return out
344
+ if suf in (".xlsx", ".xlsm", ".xls"):
345
+ sheets = pd.read_excel(f, sheet_name=None, dtype=str)
346
+ return {k: v.fillna("").to_dict("records") for k, v in sheets.items()}
347
+ df = pd.read_csv(f, dtype=str, sep=None, engine="python").fillna("")
348
+ return {f.stem: df.to_dict("records")}
349
+
350
+
351
+ def scan_table(lib, f, explicit=False):
352
+ """A parts table (has a Library Ref or Part Number + Footprint Ref column) or a BOM (headers only)."""
353
+ from . import bom_triage as T
354
+ if f.suffix.lower() in (".sqlite", ".db"):
355
+ for t, rows in read_table_file(f).items():
356
+ add_table_rows(lib, t, rows, f"table {f.name}[{t}]")
357
+ lib.files["parts table"] += 1
358
+ return
359
+ try:
360
+ if f.suffix.lower() in (".xlsx", ".xlsm"):
361
+ import pandas as pd
362
+ heads = {k: [str(c) for c in v.columns] for k, v in pd.read_excel(f, sheet_name=None, nrows=1, dtype=str).items()}
363
+ else:
364
+ heads = {f.stem: [str(c) for c in T._read_csv(f).iloc[0]]}
365
+ except Exception:
366
+ return
367
+ if any({"libraryref", "footprintref"} & {_norm(c) for c in hs} for hs in heads.values()):
368
+ for t, rows in read_table_file(f).items():
369
+ add_table_rows(lib, t, rows, f"table {f.name}[{t}]")
370
+ lib.files["parts table"] += 1
371
+ return
372
+ try:
373
+ lines, mapping = T.read_bom(f)
374
+ except Exception:
375
+ return
376
+ raw = T._read_csv(f) if f.suffix.lower() not in (".xlsx", ".xlsm") else None
377
+ headers = list(mapping.values())
378
+ if raw is not None:
379
+ row = max(range(min(10, len(raw))), key=lambda i: len(T.map_headers(list(raw.iloc[i]))))
380
+ headers = [str(h).strip() for h in raw.iloc[row]]
381
+ lib.boms.append({"file": str(f), "headers": headers, "mapping": mapping, "lines": len(lines)})
382
+ lib.files["BOM"] += 1
383
+
384
+
385
+ def _ini_sections(text):
386
+ out, cur = [], None
387
+ for ln in text.splitlines():
388
+ ln = ln.strip()
389
+ m = re.fullmatch(r"\[(.+)\]", ln)
390
+ if m:
391
+ cur = (m.group(1), {})
392
+ out.append(cur)
393
+ elif cur and "=" in ln:
394
+ k, v = ln.split("=", 1)
395
+ cur[1][k.strip()] = v.strip()
396
+ return out
397
+
398
+
399
+ def scan_dblib(lib, f):
400
+ secs = _ini_sections(f.read_text(encoding="utf-8", errors="replace"))
401
+ links = next((d for n, d in secs if n == "DatabaseLinks"), {})
402
+ tables = [d.get("TableName", "") for n, d in secs if n.startswith("Table") and d.get("Enabled", "True") != "False"]
403
+ fields = defaultdict(list)
404
+ for n, d in secs:
405
+ if n.startswith("FieldMap"):
406
+ opt = dict(kv.split("=", 1) for kv in d.get("Options", "").split("|") if "=" in kv)
407
+ pname = opt.get("ParameterName", opt.get("FieldNameOnly", "")).strip("[]")
408
+ if pname:
409
+ fields[opt.get("TableNameOnly", "")].append(pname)
410
+ lib.files["DbLib"] += 1
411
+ # the data file: LibraryDatabasePath (relative when DatabasePathRelative=1), else the connection string
412
+ cands = [links.get("LibraryDatabasePath", "")]
413
+ cs = links.get("ConnectionString", "")
414
+ cands += re.findall(r"(?:Data Source|Database|DBQ)=([^;\"]+)", cs, re.I)
415
+ data = None
416
+ for c in filter(None, cands):
417
+ for p in (f.parent / c, Path(c), f.parent / Path(c.replace("\\", "/")).name):
418
+ try:
419
+ if p.is_file():
420
+ data = p
421
+ break
422
+ except OSError:
423
+ continue
424
+ if data:
425
+ break
426
+ rows_read = False
427
+ if data and data.suffix.lower() in (".xlsx", ".xlsm", ".xls", ".sqlite", ".db", ".csv"):
428
+ try:
429
+ for t, rows in read_table_file(data).items():
430
+ add_table_rows(lib, t, rows, f"DbLib {f.name} [{t}]")
431
+ rows_read = True
432
+ except Exception as e:
433
+ lib.warnings.append(f"{f.name}: could not read {data.name} ({type(e).__name__}: {e}); used the field mappings only")
434
+ elif data:
435
+ lib.warnings.append(f"{f.name}: {data.suffix} data ({data.name}) can't be read here; used the field mappings only")
436
+ if not rows_read:
437
+ lib.warnings.append(f"{f.name}: data file not found or not readable; parameter names come from its field mappings")
438
+ for t in tables or list(fields):
439
+ params = {p: "" for p in fields.get(t, []) + fields.get(t.rstrip("$"), []) if _norm(p) not in NOT_PARAMS}
440
+ lib.components.append({"name": "", "category": category_of(t) or "Misc", "folder": t.rstrip("$"), "params": params,
441
+ "symbol": "", "footprint": "", "designator": "", "description": "", "origin": f"DbLib {f.name} [{t}] (field map)",
442
+ "fieldmap_only": True})
443
+
444
+
445
+ # ------------------------------------------------------------------ name mining
446
+ def parse_token_value(tok, cat):
447
+ """'10K' '4K7' '100N' '4u7' '100nF' '0R1' -> value in base units, for a category; None if it is not a value."""
448
+ from .kicad_index import parse_value
449
+ if not re.match(r"^\d", tok) or cat not in RU.UNIT:
450
+ return None
451
+ s = re.sub(r"(?i)(ohms?|Ω)$", "", tok)
452
+ if cat == "Resistors":
453
+ s = s.replace("K", "k")
454
+ if re.fullmatch(r"\d+", s) is None and not re.fullmatch(r"\d+(\.\d+)?[kMGR]\d*|\d+R\d*", s):
455
+ return None
456
+ else:
457
+ if re.fullmatch(r"\d+(\.\d+)?", s):
458
+ return None # a bare number is not a capacitance
459
+ s = re.sub(r"[PNU]", lambda m: m.group(0).lower(), s)
460
+ if cat == "Inductors":
461
+ s = re.sub(r"M(?=\d|H|$)", "m", s)
462
+ if not re.fullmatch(r"\d+(\.\d+)?[pnumµ]\d*[FfHh]?|\d+[pnumµ]\d+[FfHh]?", s):
463
+ return None
464
+ return parse_value(s)
465
+
466
+
467
+ def value_styles(tok, cat):
468
+ v = parse_token_value(tok, cat)
469
+ if v is None:
470
+ return None
471
+ return [s for s in RU.VALUE_STYLES if s != "keep" and RU.fmt_number(v, cat, s) == tok]
472
+
473
+
474
+ def _params_ci(params, *names):
475
+ low = {_norm(k): v for k, v in params.items()}
476
+ for n in names:
477
+ for syn in PARAM_SYN.get(n, (n,)):
478
+ if low.get(_norm(syn)):
479
+ return str(low[_norm(syn)])
480
+ return ""
481
+
482
+
483
+ def classify(tok, comp, styles_seen):
484
+ """One name token -> placeholder or literal."""
485
+ cat, params = comp["category"], comp["params"]
486
+ mpn, mfr = _params_ci(params, "Manufacturer Part Number"), _params_ci(params, "Manufacturer")
487
+ up = tok.upper()
488
+ if mpn and up == mpn.upper():
489
+ return "{mpn}"
490
+ if mfr and up == mfr.upper():
491
+ return "{manufacturer}"
492
+ if tok in IMPERIAL and cat in RU.PASSIVES + ("Diodes",):
493
+ return "{package}"
494
+ if tok in METRIC and tok not in IMPERIAL and cat in RU.PASSIVES + ("Diodes",):
495
+ return "{metric}"
496
+ m = re.fullmatch(r"([A-Za-z]{1,4})(01005|0201|0402|0603|0805|1206|1210|1812|2010|2512)", tok)
497
+ if m and cat in RU.PASSIVES + ("Diodes",):
498
+ lit = m.group(1)
499
+ lit = "{cat3}" if lit.upper() == RU.CAT3.get(cat) else "{ref}" if lit.upper() == RU.CAT_REF.get(cat) else lit
500
+ return lit + "{package}"
501
+ if re.fullmatch(r"\d+(\.\d+)?%", tok):
502
+ return "{tolerance}"
503
+ if re.fullmatch(r"\d+(\.\d+)?V", tok, re.I) and cat in RU.PASSIVES + ("Diodes",):
504
+ return "{voltage}"
505
+ if re.fullmatch(r"X[5-8][RSTPV]|C0G|NP0|Y5V|Z5U", up):
506
+ return "{dielectric}"
507
+ if re.fullmatch(r"(\d+/\d+|\d+(\.\d+)?)W", tok, re.I):
508
+ return "{power}"
509
+ st = value_styles(tok, cat) if cat in RU.PASSIVES else None
510
+ if st is not None:
511
+ styles_seen.append(tuple(st) or ("keep",))
512
+ return "{value}"
513
+ if up == RU.CAT3.get(cat) and cat in RU.PASSIVES:
514
+ return "{cat3}"
515
+ if up == RU.CAT_REF.get(cat) and cat in RU.PASSIVES:
516
+ return "{ref}"
517
+ return None # literal
518
+
519
+
520
+ def looks_like_mpn(name):
521
+ n = re.sub(r"[^A-Za-z0-9]", "", name)
522
+ return len(n) >= 5 and re.search(r"\d", n) and re.search(r"[A-Za-z]", n) and not re.search(r"(?<!\d)(0402|0603|0805|1206)(?!\d)", name)
523
+
524
+
525
+ def name_template(comp, styles_seen):
526
+ """Component name -> template string, or None when it is only literals (a free-form name)."""
527
+ name = comp["name"]
528
+ mpn = _params_ci(comp["params"], "Manufacturer Part Number")
529
+ if mpn and name.upper() == mpn.upper():
530
+ return "{mpn}"
531
+ parts = re.split(r"([_\- ]+)", name)
532
+ out, fields = [], 0
533
+ for i, p in enumerate(parts):
534
+ if i % 2:
535
+ out.append(p)
536
+ continue
537
+ c = classify(p, comp, styles_seen) if p else ""
538
+ if c:
539
+ fields += 1
540
+ out.append(c if c is not None else p)
541
+ if not fields:
542
+ return "{mpn}?" if looks_like_mpn(name) and comp["category"] not in RU.PASSIVES else None
543
+ return "".join(out)
544
+
545
+
546
+ def _tokens(t):
547
+ return [x for x in re.split(r"(\{\w+\})", t) if x]
548
+
549
+
550
+ def reduces_to(small, big):
551
+ """True when `small` is `big` with some fields (and one neighbouring separator each) left out, i.e. what
552
+ rules.render() makes of `big` when those fields are empty."""
553
+ if small == big:
554
+ return True
555
+ names = RU._template_fields(big)
556
+ if not names or len(names) > 8:
557
+ return False
558
+ import itertools
559
+ for k in range(1, len(names)):
560
+ for drop in itertools.combinations(range(len(names)), k):
561
+ f = {n: ("" if i in drop else f"\x00{n}\x00") for i, n in enumerate(names)}
562
+ if len(set(names)) != len(names):
563
+ break
564
+ r = re.sub(r"\x00(\w+)\x00", r"{\1}", RU.render(big, f))
565
+ if r == small:
566
+ return True
567
+ return False
568
+
569
+
570
+ def best_template(templates):
571
+ """Counter of templates -> [(template, votes incl. reductions, own count)] best first."""
572
+ cands = []
573
+ for t, n in templates.items():
574
+ votes = sum(m for s, m in templates.items() if reduces_to(s, t))
575
+ cands.append((t, votes, n))
576
+ cands.sort(key=lambda x: (-x[1], -len(RU._template_fields(x[0])), -x[2], x[0]))
577
+ return cands
578
+
579
+
580
+ class Setting(dict):
581
+ """{key, value, confidence, evidence [...], source, alternatives [{value, share}], question?}"""
582
+
583
+
584
+ def _label(c):
585
+ return "high" if c >= 0.8 else "medium" if c >= 0.5 else "low"
586
+
587
+
588
+ def mk(key, value, confidence, evidence, source, alternatives=(), ask=None):
589
+ s = Setting(key=key, value=value, confidence=round(confidence, 2), level=_label(confidence),
590
+ evidence=list(evidence)[:6], source=source, alternatives=list(alternatives))
591
+ if ask:
592
+ s["question"] = ask
593
+ return s
594
+
595
+
596
+ def _origins(comps, n=3):
597
+ c = Counter(x["origin"] for x in comps)
598
+ more = f" and {len(c) - n} more" if len(c) > n else ""
599
+ return ", ".join(f"{k} ({v})" for k, v in c.most_common(n)) + more
600
+
601
+
602
+ def infer_naming(lib):
603
+ """naming.component / passive / by_category / value_style / footprint."""
604
+ out = []
605
+ comps = [c for c in lib.components if c.get("name") and not c.get("fieldmap_only")]
606
+ by_cat = defaultdict(list)
607
+ for c in comps:
608
+ by_cat[c["category"]].append(c)
609
+ styles_seen, per_cat = [], {}
610
+ for cat, cs in by_cat.items():
611
+ temps, examples = Counter(), defaultdict(list)
612
+ for c in cs:
613
+ t = name_template(c, styles_seen if cat in RU.PASSIVES else [])
614
+ if t is None:
615
+ continue
616
+ t = "{mpn}" if t == "{mpn}?" else t
617
+ temps[t] += 1
618
+ examples[t].append(c["name"])
619
+ if temps:
620
+ per_cat[cat] = (best_template(temps), temps, examples, cs)
621
+ other = [c for c in per_cat if c not in RU.PASSIVES]
622
+ pooled = Counter()
623
+ for c in other:
624
+ pooled.update(per_cat[c][1])
625
+ pooled_ex = defaultdict(list)
626
+ for c in other:
627
+ for t, ex in per_cat[c][2].items():
628
+ pooled_ex[t] += ex
629
+ if pooled:
630
+ ranked = best_template(pooled)
631
+ t, votes, _ = ranked[0]
632
+ total = sum(pooled.values())
633
+ conf = votes / total
634
+ unmatched = sum(len(per_cat[c][3]) for c in other) - total
635
+ if unmatched:
636
+ conf *= total / (total + unmatched)
637
+ alts = [{"value": a, "share": round(v / total, 2)} for a, v, _ in ranked[1:3]]
638
+ ev = [f"{ex}" for ex in pooled_ex[t][:3]] + [f"{votes} of {total + unmatched} non-passive names fit"]
639
+ if not any(_params_ci(c["params"], "Manufacturer Part Number") for c in comps) and t == "{mpn}":
640
+ ev.append("names look like manufacturer part numbers (no MPN parameter found to confirm)")
641
+ conf *= 0.8
642
+ ask = _question(f"Non-passive part names: which pattern do you use?", t, ranked, total)
643
+ out.append(mk("naming.component", t, conf, ev, _origins([c for k in other for c in per_cat[k][3]]), alts, ask))
644
+ passive_best = {}
645
+ for cat in RU.PASSIVES:
646
+ if cat not in per_cat:
647
+ continue
648
+ ranked, temps, examples, cs = per_cat[cat]
649
+ t, votes, _ = ranked[0]
650
+ total = len(cs)
651
+ alts = [{"value": a, "share": round(v / total, 2)} for a, v, _ in ranked[1:3]]
652
+ ev = examples[t][:3] + [f"{votes} of {total} {cat.lower()} names fit"]
653
+ passive_best[cat] = mk(f"naming.by_category.{cat}", t, votes / total, ev, _origins(cs), alts,
654
+ _question(f"{cat} names: which pattern do you use?", t, ranked, total))
655
+ vals = {s["value"] for s in passive_best.values()}
656
+ if len(vals) == 1 and len(passive_best) >= 2:
657
+ s = min(passive_best.values(), key=lambda x: x["confidence"])
658
+ merged = mk("naming.passive", s["value"], s["confidence"],
659
+ [e for x in passive_best.values() for e in x["evidence"][:2]], "; ".join(x["source"] for x in passive_best.values()),
660
+ s["alternatives"], s.get("question"))
661
+ out.append(merged)
662
+ else:
663
+ out += passive_best.values()
664
+ # value style
665
+ if styles_seen:
666
+ votes = Counter()
667
+ for st in styles_seen:
668
+ for s in st:
669
+ votes[s] += 1
670
+ order = ["rkm", "rkm_lower", "si_unit", "si", "si_upper", "keep"]
671
+ ranked = sorted(votes.items(), key=lambda kv: (-kv[1], order.index(kv[0])))
672
+ best, n = ranked[0]
673
+ total = len(styles_seen)
674
+ ex = {"rkm": "4K7, 100N", "rkm_lower": "4k7, 100n", "si": "4.7k, 100n", "si_upper": "4.7K, 100N", "si_unit": "100nF", "keep": "as the BOM writes it"}
675
+ ask = None
676
+ if len(ranked) > 1 and ranked[1][1] == n and {best, ranked[1][0]} not in ({"rkm", "si_upper"}, {"rkm_lower", "si"}):
677
+ ask = {"text": "Values in names: which style?", "choices": [r[0] for r in ranked[:3]], "default": best}
678
+ out.append(mk("naming.value_style", best, n / total, [f"{n} of {total} value tokens are written {best} ({ex[best]})"],
679
+ "component names", [{"value": k, "share": round(v / total, 2)} for k, v in ranked[1:3]], ask))
680
+ out += infer_footprint_naming(lib)
681
+ return out
682
+
683
+
684
+ def _question(text, best, ranked, total):
685
+ if len(ranked) < 2:
686
+ return None
687
+ share, second = ranked[0][1] / total, ranked[1][1] / total
688
+ if share < 0.6 and second >= 0.2 and not reduces_to(ranked[1][0], best):
689
+ return {"text": text, "choices": [r[0] for r in ranked[:3]], "default": best,
690
+ "counts": {r[0]: r[2] for r in ranked[:3]}}
691
+ return None
692
+
693
+
694
+ FP_KICAD = re.compile(r"_\d{4}Metric|_P\d+(\.\d+)?mm|\d+(\.\d+)?x\d+(\.\d+)?mm|^(SOT|SOD|SOIC|TSSOP|MSOP|QFN|LQFP|DFN)-\d")
695
+
696
+
697
+ def infer_footprint_naming(lib):
698
+ names = [(f["name"], f["origin"]) for f in lib.footprints if f["kind"] == "altium"] or \
699
+ [(f["name"], f["origin"]) for f in lib.footprints]
700
+ names += [(c["footprint"], c["origin"]) for c in lib.components if c.get("footprint")]
701
+ names = list(dict.fromkeys(names))
702
+ if not names:
703
+ return []
704
+ kinds, ex = Counter(), defaultdict(list)
705
+ chip_t = Counter()
706
+ for n, o in names:
707
+ k = "ipc7351" if RU.IPC_NAME.match(n) else "kicad" if FP_KICAD.search(n) else "other"
708
+ kinds[k] += 1
709
+ ex[k].append(n)
710
+ if k == "other":
711
+ m = re.fullmatch(r"([A-Za-z]{0,4})([_\-]?)(0201|0402|0603|0805|1206|1210|1812|2010|2512)([_\-]?)([A-Za-z]*)", n)
712
+ if m:
713
+ pre = m.group(1).upper()
714
+ pre_t = "{ref}" if pre in ("R", "C", "L", "D") else "{cat3}" if pre in ("RES", "CAP", "IND") else m.group(1)
715
+ chip_t[f"{pre_t}{m.group(2)}{{package}}{m.group(4)}{m.group(5)}"] += 1
716
+ total = sum(kinds.values())
717
+ best, n = kinds.most_common(1)[0]
718
+ if best == "other":
719
+ if chip_t:
720
+ t, m = chip_t.most_common(1)[0]
721
+ return [mk("naming.footprint", t, m / total * 0.9, ex["other"][:3] + [f"{m} of {total} footprint names fit (chip packages; others keep their names)"],
722
+ "footprint names", [{"value": k, "share": round(v / total, 2)} for k, v in kinds.most_common() if k != "other"])]
723
+ return [mk("naming.footprint", "kicad", 0.3, ex["other"][:3] + ["footprint names follow no pattern ecadlib knows; KiCad names kept"],
724
+ "footprint names")]
725
+ alts = [{"value": k, "share": round(v / total, 2)} for k, v in kinds.most_common() if k != best and k != "other"]
726
+ ask = None
727
+ if len(kinds) > 1 and n / total < 0.6 and alts and alts[0]["share"] >= 0.25:
728
+ ask = {"text": "Footprint names: KiCad style (R_0603_1608Metric) or IPC-7351 (RESC1608X55N)?", "choices": [best] + [a["value"] for a in alts], "default": best}
729
+ return [mk("naming.footprint", best, n / total, ex[best][:3] + [f"{n} of {total} footprint names are {best} style"],
730
+ "footprint names", alts, ask)]
731
+
732
+
733
+ # ------------------------------------------------------------------ parameters, folders
734
+ def infer_parameters(lib):
735
+ out = []
736
+ comps = [c for c in lib.components if not c.get("fieldmap_only")]
737
+ altium = [c for c in comps if not c["origin"].startswith("KiCad")]
738
+ pool = altium or comps
739
+ fieldmaps = [c for c in lib.components if c.get("fieldmap_only")]
740
+ if not pool and not fieldmaps:
741
+ return out
742
+ presence, values = Counter(), defaultdict(Counter)
743
+ for c in pool:
744
+ for k, v in c["params"].items():
745
+ if str(v).strip():
746
+ presence[k] += 1
747
+ values[k][str(v).strip()] += 1
748
+ names_seen = Counter(k for c in pool + fieldmaps for k in c["params"])
749
+ # house names for ecadlib's parameters
750
+ names, ev_names = {}, []
751
+ for canon in RU.CANONICAL_PARAMS:
752
+ hits = [(k, names_seen[k]) for k in names_seen if canonical_of(k) == canon]
753
+ if canon == "ComponentLink2URL" and not altium:
754
+ hits = [] # KiCad's Datasheet field is KiCad's own
755
+ if not hits:
756
+ continue
757
+ house, n = max(hits, key=lambda kv: (kv[1], kv[0] == canon))
758
+ if house != canon and not (canon == "ComponentLink2URL" and _norm(house) == "componentlink2url"):
759
+ names[canon] = house
760
+ ev_names.append(f"{house} = {canon} ({n} parts)")
761
+ src = _origins(pool or fieldmaps)
762
+ if names:
763
+ out.append(mk("parameters.names", names, 0.9 if pool else 0.7, ev_names, src))
764
+ n = len(pool)
765
+ if n >= 3:
766
+ req = {}
767
+ for k, m in presence.most_common():
768
+ if m / n >= 0.9 and _norm(k) not in NOT_PARAMS:
769
+ canon = canonical_of(k)
770
+ if canon == "ComponentLink2URL":
771
+ req.setdefault("Datasheet", m)
772
+ elif canon not in ("Supplier 1", "Supplier Part Number 1", "Supplier 2", "Supplier Part Number 2", "Value"):
773
+ req.setdefault(k, m)
774
+ nd = sum(bool(str(c.get("description") or "").strip()) for c in pool)
775
+ if nd / n >= 0.9:
776
+ req["Description"] = nd
777
+ if req:
778
+ out.append(mk("parameters.required", list(req), min(1.0, 0.6 + 0.04 * n),
779
+ [f"{k}: filled on {m} of {n} parts" for k, m in req.items()], src))
780
+ defaults = {}
781
+ for k, cnt in values.items():
782
+ if canonical_of(k) or _norm(k) in NOT_PARAMS or re.search(r"url|link|date|number|pn$|part|lcsc|supplier|vendor|"
783
+ r"digi|mouser|sku|order|code|id$", k, re.I):
784
+ continue
785
+ v, m = cnt.most_common(1)[0]
786
+ if len(cnt) == 1 and m >= max(5, 0.95 * n) and len(v) <= 40:
787
+ defaults[k] = v
788
+ if defaults:
789
+ out.append(mk("parameters.defaults", defaults, 0.7, [f"{k} = {v} on every part ({presence[k]})" for k, v in defaults.items()], src))
790
+ slots = [int(m.group(1)) for k in names_seen for m in [re.search(r"(?:supplier|vendor|distributor)\D*(\d+)$", k, re.I)] if m]
791
+ if slots and max(slots) != 2:
792
+ out.append(mk("parameters.supplier_slots", max(slots), 0.8, [f"supplier columns up to {max(slots)}"], src))
793
+ return out
794
+
795
+
796
+ def infer_folders(lib):
797
+ comps = [c for c in lib.components if c.get("folder")]
798
+ a365 = [c for c in comps if c["origin"].startswith("A365")]
799
+ use = a365 or [c for c in comps if c["origin"].startswith(("DbLib", "table"))]
800
+ if not use:
801
+ return []
802
+ out = []
803
+ by = defaultdict(Counter)
804
+ for c in use:
805
+ cat = category_of(c["folder"]) or c["category"]
806
+ by[cat][c["folder"]] += 1
807
+ folders = [f for cnt in by.values() for f in cnt]
808
+ if a365:
809
+ segs = [f.split("/") for f in folders]
810
+ common = []
811
+ for parts in zip(*segs):
812
+ if len(set(parts)) == 1:
813
+ common.append(parts[0])
814
+ else:
815
+ break
816
+ root = "/".join(common) if len(common) < min(len(s) for s in segs) else "/".join(common[:-1])
817
+ else:
818
+ root = RU.DEFAULTS["folders"]["root"]
819
+ mapping, ev = {}, []
820
+ for cat, cnt in sorted(by.items()):
821
+ f, m = cnt.most_common(1)[0]
822
+ full = f if a365 else f"{root}/{f}"
823
+ if full != f"{root}/{cat}":
824
+ mapping[cat] = full
825
+ ev.append(f"{cat}: {full} ({m} parts)")
826
+ total = sum(sum(c.values()) for c in by.values())
827
+ hits = sum(c.most_common(1)[0][1] for c in by.values())
828
+ if root and root != RU.DEFAULTS["folders"]["root"]:
829
+ out.append(mk("folders.root", root, 0.8, [f"every folder starts with {root}/"], _origins(use)))
830
+ if mapping:
831
+ out.append(mk("folders.map", mapping, hits / total, ev, _origins(use)))
832
+ return out
833
+
834
+
835
+ # ------------------------------------------------------------------ layers, density
836
+ def infer_layers(lib):
837
+ fps = [f for f in lib.footprints if f["kind"] == "altium" and f["pads"]]
838
+ if not fps:
839
+ return []
840
+ votes = {k: Counter() for k in ("courtyard", "assembly", "body3d", "designator", "silkscreen")}
841
+ for f in fps:
842
+ px = [(x - w / 2, y - h / 2, x + w / 2, y + h / 2) for _, x, y, w, h in f["pads"]]
843
+ pb = (min(b[0] for b in px), min(b[1] for b in px), max(b[2] for b in px), max(b[3] for b in px))
844
+ mech = {l: b for l, b in f["graphics"].items() if b and 57 <= l <= 72}
845
+ enclosing = [l for l, b in mech.items() if b[0] <= pb[0] + 0.01 and b[1] <= pb[1] + 0.01 and b[2] >= pb[2] - 0.01 and b[3] >= pb[3] - 0.01]
846
+ area = lambda l: (mech[l][2] - mech[l][0]) * (mech[l][3] - mech[l][1])
847
+ crt = max(enclosing, key=area) if enclosing else None
848
+ if crt:
849
+ votes["courtyard"][crt] += 1
850
+ for t, l in f["texts"]:
851
+ if t == ".Comment" and 57 <= l <= 72:
852
+ votes["assembly"][l] += 1
853
+ if t == ".Designator":
854
+ votes["designator"][l] += 1
855
+ for l in mech:
856
+ if l != crt and not any(t == ".Comment" for t, _ in f["texts"]):
857
+ votes["assembly"][l] += 1
858
+ for l in f["bodies"]:
859
+ if 57 <= l <= 72: # the writer's (and rules') mechanical layers: 1..16
860
+ votes["body3d"][l] += 1
861
+ ov = [l for l in f["graphics"] if l in (33, 34)]
862
+ if ov:
863
+ votes["silkscreen"][33 if 33 in ov else ov[0]] += 1
864
+ out = []
865
+ for role, cnt in votes.items():
866
+ if not cnt:
867
+ continue
868
+ l, n = cnt.most_common(1)[0]
869
+ total = sum(cnt.values())
870
+ alts = [{"value": RU.layer_name(k), "share": round(v / total, 2)} for k, v in cnt.most_common()[1:3]]
871
+ ask = None
872
+ if alts and n / total < 0.6:
873
+ ask = {"text": f"Which layer holds the {role}?", "choices": [RU.layer_name(l)] + [a["value"] for a in alts], "default": RU.layer_name(l)}
874
+ how = {"courtyard": "the outline that encloses all pads", "assembly": "the .Comment text / body outline",
875
+ "body3d": "3D bodies", "designator": ".Designator text", "silkscreen": "silkscreen lines"}[role]
876
+ out.append(mk(f"layers.{role}", RU.layer_name(l), n / total,
877
+ [f"{RU.layer_name(l)} holds {how} in {n} of {total} footprints"] + [f["name"] for f in fps[:2]],
878
+ _origins_fp(fps), alts, ask))
879
+ return out
880
+
881
+
882
+ def _origins_fp(fps):
883
+ c = Counter(f["origin"] for f in fps)
884
+ return ", ".join(f"{k} ({v})" for k, v in c.most_common(3)) + (f" and {len(c) - 3} more" if len(c) > 3 else "")
885
+
886
+
887
+ _Z_CACHE = {}
888
+
889
+
890
+ def _chip_z(imp, kind):
891
+ """Toe-to-toe length Z of footprint_gen's chip land pattern for an imperial size at densities A/B/C."""
892
+ from . import footprint_gen as F
893
+ key = (imp, kind)
894
+ if key not in _Z_CACHE:
895
+ specs = Path(F.__file__).with_name("package_specs")
896
+ met = {"0603": "1608", "0805": "2012", "1206": "3216", "1210": "3225"}.get(imp)
897
+ f = next((p for p in (specs / f"{kind}_{imp}_{met}Metric.json", specs / f"R_{imp}_{met}Metric.json") if p.exists()), None)
898
+ if not f:
899
+ _Z_CACHE[key] = None
900
+ else:
901
+ spec = json.loads(f.read_text(encoding="utf-8"))
902
+ zs = {}
903
+ for d in "ABC":
904
+ _, rep = F.build(spec, d)
905
+ ps = rep["pads"]
906
+ zs[d] = max(abs(p["x"]) + p["w"] / 2 for p in ps) * 2
907
+ _Z_CACHE[key] = zs
908
+ return _Z_CACHE[key]
909
+
910
+
911
+ def infer_density(lib):
912
+ votes, ev = Counter(), []
913
+ for f in lib.footprints:
914
+ m = RU.IPC_NAME.match(f["name"])
915
+ if m:
916
+ d = m.groups()[-1].upper()
917
+ votes[d] += 2
918
+ if len(ev) < 2:
919
+ ev.append(f"{f['name']}: IPC name ends in {d}")
920
+ continue
921
+ m = re.search(r"density[- ]?([ABC])\b", f.get("description", ""))
922
+ if m:
923
+ votes[{"A": "M", "B": "N", "C": "L"}[m.group(1)]] += 1
924
+ continue
925
+ if len(f["pads"]) == 2:
926
+ from .kicad_index import package_of
927
+ imp = package_of(f["name"])
928
+ km = re.match(r"^(R|C|L)[_\d]", f["name"])
929
+ kind = km.group(1) if km else "R"
930
+ zs = _chip_z(imp, kind) if imp in ("0603", "0805", "1206", "1210") else None
931
+ if not zs:
932
+ continue
933
+ (_, x1, y1, w1, h1), (_, x2, y2, w2, h2) = f["pads"]
934
+ horiz = abs(x1 - x2) >= abs(y1 - y2)
935
+ z = (abs(x1 - x2) + (w1 + w2) / 2) if horiz else (abs(y1 - y2) + (h1 + h2) / 2)
936
+ d, dz = min(((d, abs(z - zz)) for d, zz in zs.items()), key=lambda t: t[1])
937
+ if dz <= 0.3:
938
+ votes[{"A": "M", "B": "N", "C": "L"}[d]] += 1
939
+ if len(ev) < 4:
940
+ ev.append(f"{f['name']}: pads {z:.2f} mm toe to toe, IPC {imp} {'MNL'['ABC'.index(d)]} = {zs[d]:.2f}")
941
+ if not votes:
942
+ return []
943
+ best, n = votes.most_common(1)[0]
944
+ total = sum(votes.values())
945
+ alts = [{"value": k, "share": round(v / total, 2)} for k, v in votes.most_common()[1:3]]
946
+ ask = {"text": "IPC-7351 density for new footprints?", "choices": [best] + [a["value"] for a in alts], "default": best} \
947
+ if alts and n / total < 0.6 else None
948
+ return [mk("footprint.density", best, n / total, ev + [f"{n} of {total} votes for {best}"], _origins_fp(lib.footprints), alts, ask)]
949
+
950
+
951
+ def infer_symbols(lib):
952
+ """[symbols] pin_length and grid from the IC symbols in scanned SchLibs (8+ pins; passives say little), when they
953
+ differ from ecadlib's (300 mil pins, 100 mil grid)."""
954
+ syms = [(n, ps) for n, ps in lib.sym_pins if len(ps) >= 8]
955
+ if not syms:
956
+ return []
957
+ from math import gcd
958
+ lengths, grids = Counter(), Counter()
959
+ for _, ps in syms:
960
+ lengths.update(L * 10 for _, _, L in ps if L > 0)
961
+ g = 0
962
+ for x, y, _ in ps:
963
+ g = gcd(gcd(g, x), y)
964
+ grids[100 if g and g % 10 == 0 else 50 if g and g % 5 == 0 else 0] += 1
965
+ out = []
966
+ if lengths:
967
+ L, n = lengths.most_common(1)[0]
968
+ total = sum(lengths.values())
969
+ g = 50 if grids.most_common(1)[0][0] == 50 else 100
970
+ if L % 50 == 0 and L % g == 0 and L != RU.DEFAULTS["symbols"]["pin_length"]: # only what differs from ecadlib's
971
+ out.append(mk("symbols.pin_length", L, n / total, [f"{n} of {total} pins are {L} mil long"] + [n_ for n_, _ in syms[:2]],
972
+ f"{len(syms)} SchLib symbols"))
973
+ g, n = grids.most_common(1)[0]
974
+ if g and g != RU.DEFAULTS["symbols"]["grid"]:
975
+ out.append(mk("symbols.grid", g, n / len(syms), [f"pins of {n} of {len(syms)} symbols sit on a {g} mil grid"], f"{len(syms)} SchLib symbols"))
976
+ return out
977
+
978
+
979
+ def infer_bom(lib):
980
+ from . import bom_triage as T
981
+ cols, ev = {}, []
982
+ for b in lib.boms:
983
+ for f, h in b["mapping"].items():
984
+ if _norm(h) and T._norm(h) not in T.FIELDS.get(f, []):
985
+ cols.setdefault(f, Counter())[h] += 1
986
+ out = {f: c.most_common(1)[0][0] for f, c in cols.items()}
987
+ if not out:
988
+ return []
989
+ ev = [f"{h} -> {f} (a fuzzy match today; written down so it never changes)" for f, h in out.items()]
990
+ return [mk("bom.columns", out, 0.75, ev, ", ".join(Path(b["file"]).name for b in lib.boms[:3]))]
991
+
992
+
993
+ # ------------------------------------------------------------------ proposal
994
+ def infer(lib):
995
+ settings = []
996
+ for fn in (infer_naming, infer_parameters, infer_folders, infer_layers, infer_density, infer_bom, infer_symbols):
997
+ try:
998
+ settings += fn(lib)
999
+ except Exception as e: # one analysis failing must not lose the others
1000
+ lib.warnings.append(f"{fn.__name__}: {type(e).__name__}: {e}")
1001
+ return settings
1002
+
1003
+
1004
+ def _set(rules, key, value):
1005
+ parts = key.split(".")
1006
+ d = rules
1007
+ for p in parts[:-1]:
1008
+ d = d.setdefault(p, {})
1009
+ d[parts[-1]] = value
1010
+
1011
+
1012
+ def to_rules(settings, choices=None):
1013
+ """Settings (+ the person's choices {key: value}) -> a raw rules dict (only the inferred keys)."""
1014
+ raw = {}
1015
+ for s in settings:
1016
+ v = (choices or {}).get(s["key"], s["value"])
1017
+ _set(raw, s["key"], v)
1018
+ return raw
1019
+
1020
+
1021
+ def notes_for(settings, choices=None):
1022
+ notes = {}
1023
+ for s in settings:
1024
+ k = s["key"]
1025
+ chosen = k in (choices or {}) and (choices or {})[k] != s["value"]
1026
+ line = [f"from {s['source']}" if s.get("source") else "inferred",
1027
+ f"confidence {s['level']} ({int(s['confidence'] * 100)}%)" + ("; you chose this value" if chosen else "")]
1028
+ line += [f" e.g. {e}" for e in s["evidence"][:3]]
1029
+ if s.get("alternatives"):
1030
+ line.append(" also seen: " + ", ".join(f"{a['value']} ({int(a['share'] * 100)}%)" for a in s["alternatives"]))
1031
+ notes[k] = line
1032
+ return notes
1033
+
1034
+
1035
+ def propose(paths=(), a365=None, choices=None, existing=None, extra=None):
1036
+ """Scan, infer and render. Returns {settings, questions, rules (inferred keys), toml, scanned, warnings, diff?}.
1037
+ choices: {setting key: value} answers that replace the best guesses. existing: path of a current rules file,
1038
+ whose explicit keys are kept unless a setting replaces them; a diff against it is included. extra: sections
1039
+ to write as well (the `init` setup answers: [setup], [sources]); they replace the same keys of the file."""
1040
+ lib = scan(paths, a365)
1041
+ settings = infer(lib)
1042
+ choices = dict(choices or {})
1043
+ raw = to_rules(settings, choices)
1044
+ if extra:
1045
+ raw = RU.merge(raw, extra)
1046
+ old_text, old_raw = "", {}
1047
+ if existing and Path(existing).is_file():
1048
+ old_text = Path(existing).read_text(encoding="utf-8")
1049
+ old_raw = RU.parse_text(old_text, Path(existing).name)
1050
+ merged_raw = RU.merge(old_raw, raw)
1051
+ errors = []
1052
+ try:
1053
+ RU.validate(merged_raw, "proposal")
1054
+ except RU.RulesError as e:
1055
+ errors.append(str(e))
1056
+ keys = {f"{sec}.{k}" for sec, v in merged_raw.items() if isinstance(v, dict) for k in v}
1057
+ full = RU.merge(RU.DEFAULTS, merged_raw)
1058
+ header = ["# ecadlib library rules: every ecadlib tool follows this file (see `ecadlib rules -h`).",
1059
+ "# Written by `ecadlib init` from: " + (", ".join(f"{v} {k}" for k, v in lib.kinds().items()) or "nothing found") + ".",
1060
+ "# Anything not listed keeps ecadlib's default (`ecadlib rules show` prints them all)."]
1061
+ text = RU.dumps(full, notes_for(settings, choices), only=keys, header=header)
1062
+ out = {"settings": [dict(s) for s in settings], "questions": [dict(s["question"], key=s["key"]) for s in settings if s.get("question")],
1063
+ "rules": merged_raw, "toml": text, "scanned": lib.kinds(), "components": len(lib.components),
1064
+ "footprints": len(lib.footprints), "boms": len(lib.boms), "warnings": lib.warnings, "errors": errors}
1065
+ if old_text:
1066
+ out["diff"] = RU.diff(old_text, text)
1067
+ out["existing"] = str(existing)
1068
+ return out
1069
+
1070
+
1071
+ def save(text, path, overwrite=False):
1072
+ """Validate and write a rules file. An existing different file is replaced only with overwrite=True (it is kept as
1073
+ <name>.bak); otherwise returns {saved: False, diff} so the caller can ask. Returns {saved, path, diff}."""
1074
+ path = Path(path)
1075
+ raw = RU.parse_text(text, path.name)
1076
+ RU.validate(raw, path.name)
1077
+ if path.exists():
1078
+ old = path.read_text(encoding="utf-8")
1079
+ if old == text:
1080
+ return {"saved": False, "path": str(path), "diff": "", "unchanged": True}
1081
+ d = RU.diff(old, text, path.name)
1082
+ if not overwrite:
1083
+ return {"saved": False, "path": str(path), "diff": d, "needs_confirm": True}
1084
+ path.with_name(path.name + ".bak").write_text(old, encoding="utf-8")
1085
+ path.write_text(text, encoding="utf-8")
1086
+ return {"saved": True, "path": str(path), "diff": d, "backup": str(path.with_name(path.name + ".bak"))}
1087
+ path.parent.mkdir(parents=True, exist_ok=True)
1088
+ path.write_text(text, encoding="utf-8")
1089
+ return {"saved": True, "path": str(path), "diff": RU.diff("", text, path.name)}
1090
+
1091
+
1092
+ # ------------------------------------------------------------------ CLI
1093
+ def _ask(q, inp=input, out=print):
1094
+ out(f"\n{q['text']}")
1095
+ for i, c in enumerate(q["choices"], 1):
1096
+ cnt = (q.get("counts") or {}).get(c)
1097
+ out(f" {i}) {c}" + (f" ({cnt} seen)" if cnt else "") + (" [best guess]" if c == q["default"] else ""))
1098
+ a = inp(f"Pick 1-{len(q['choices'])}, press Enter for the best guess, or type your own: ").strip()
1099
+ if not a:
1100
+ return q["default"]
1101
+ if a.isdigit() and 1 <= int(a) <= len(q["choices"]):
1102
+ return q["choices"][int(a) - 1]
1103
+ return a
1104
+
1105
+
1106
+ def main(argv=None, inp=input, environ=None):
1107
+ from . import firstrun as FR
1108
+ ap = argparse.ArgumentParser(prog="ecadlib init", description="set up ecadlib here: which tool, your library folders, "
1109
+ "the workspace and part sources (asked, or --yes for the defaults), plus your naming / "
1110
+ "parameter / layer conventions learned from existing libraries; writes ecadlib.toml, "
1111
+ "then runs the doctor and prints what to run next")
1112
+ ap.add_argument("--from", dest="paths", action="append", default=[], metavar="PATH",
1113
+ help="folder or file to scan (repeatable; default: the --lib folders, else the current folder): "
1114
+ ".SchLib/.PcbLib/.DbLib, parts tables, .kicad_sym/.pretty, BOMs, Altium 365 JSON")
1115
+ ap.add_argument("--a365", action="append", default=[], metavar="FILE", help="Altium 365 component list (JSON) or check-a365 --state file")
1116
+ ap.add_argument("-o", "--out", default="ecadlib.toml", help="rules file to write (default %(default)s)")
1117
+ ap.add_argument("--yes", "-y", action="store_true", help="no questions: take the defaults and the best guesses")
1118
+ ap.add_argument("--force", action="store_true", help="replace an existing rules file without asking (a .bak is kept)")
1119
+ ap.add_argument("--json", action="store_true", help="print the proposal as JSON and write nothing")
1120
+ ap.add_argument("--tool", choices=FR.TOOLS, help="the tool you make libraries for (default: asked, else both)")
1121
+ ap.add_argument("--lib", action="append", default=[], metavar="DIR",
1122
+ help="your library folder (repeatable): the default --lib of every command, and scanned for conventions")
1123
+ ap.add_argument("--workspace", help="workspace folder for uploads, plans and outputs (default: asked, else ecadlib_workspace)")
1124
+ ap.add_argument("--sources", help="part sources in order, comma-separated (kicad,easyeda,standard,datasheet; default: asked, else all)")
1125
+ ap.add_argument("--no-doctor", action="store_true", help="do not run `ecadlib doctor` at the end")
1126
+ a = ap.parse_args(argv)
1127
+ out = Path(a.out)
1128
+ interactive = inp is not input or sys.stdin.isatty()
1129
+ sources = [x.strip() for x in a.sources.split(",") if x.strip()] if a.sources else None
1130
+ for x in sources or []:
1131
+ if x not in RU.SOURCES:
1132
+ print(f"ecadlib init: unknown source {x!r}\n hint: sources are {', '.join(RU.SOURCES)}", file=sys.stderr)
1133
+ return 2
1134
+ try:
1135
+ old_raw = RU.parse_text(out.read_text(encoding="utf-8"), out.name) if out.is_file() else {}
1136
+ except (RU.RulesError, OSError):
1137
+ old_raw = {}
1138
+ ans = FR.ask_setup(old_raw, {"tool": a.tool, "libs": a.lib, "workspace": a.workspace, "sources": sources}, inp, print,
1139
+ interactive and not a.yes and not a.json, environ=environ)
1140
+ base = out.resolve().parent # [setup] paths are relative to the rules file's folder
1141
+ ans["libs"] = [l if Path(l).expanduser().is_absolute() or base == Path.cwd().resolve() else str(Path(l).resolve())
1142
+ for l in ans["libs"]]
1143
+ if base != Path.cwd().resolve() and not Path(ans["workspace"]).is_absolute() and ans["workspace"] != (old_raw.get("setup") or {}).get("workspace"):
1144
+ ans["workspace"] = str(Path(ans["workspace"]).resolve())
1145
+ paths = a.paths or [str(_abs(l, base)) for l in ans["libs"] if _abs(l, base).exists()] or ["."]
1146
+ extra = FR.setup_raw(ans)
1147
+ prop = propose(paths, a.a365, existing=out, extra=extra)
1148
+ if a.json:
1149
+ print(json.dumps({k: v for k, v in prop.items() if k != "toml"} | {"toml": prop["toml"]}, indent=1, default=str))
1150
+ return 0
1151
+ print(f"\nscanned {', '.join(paths)}: " + (", ".join(f"{v} {k}" for k, v in prop["scanned"].items()) or "nothing ecadlib can read")
1152
+ + f"; {prop['components']} parts, {prop['footprints']} footprints, {prop['boms']} BOMs")
1153
+ for w in prop["warnings"][:10]:
1154
+ print(f" note: {w}")
1155
+ if not prop["settings"]:
1156
+ print(" no conventions to learn (fine for a new library; ecadlib's defaults apply). To learn yours, point "
1157
+ "--from at your Altium/KiCad library folders, a DbLib, an Altium 365 export or a BOM")
1158
+ for s in prop["settings"]:
1159
+ v = json.dumps(s["value"]) if isinstance(s["value"], (dict, list)) else str(s["value"]) # pin_length is a number
1160
+ print(f" {s['key']:<34} {v[:60]:<60} {s['level']:<6} {s['evidence'][0][:70] if s['evidence'] else ''}")
1161
+ choices = {}
1162
+ if prop["questions"] and not a.yes:
1163
+ if not interactive:
1164
+ print("(not a terminal: taking the best guesses; run with --yes to say so)")
1165
+ else:
1166
+ for q in prop["questions"]:
1167
+ choices[q["key"]] = _ask(q, inp)
1168
+ prop = propose(paths, a.a365, choices, existing=out, extra=extra)
1169
+ if prop["errors"]:
1170
+ print("error: " + "; ".join(prop["errors"]), file=sys.stderr)
1171
+ return 2
1172
+ done = lambda: FR.finish_init(ans, out, environ=environ, run_doctor=not a.no_doctor) and None
1173
+ if out.exists():
1174
+ res = save(prop["toml"], out, overwrite=False)
1175
+ if res.get("unchanged"):
1176
+ print(f"{out} already says this; nothing written")
1177
+ done()
1178
+ return 0
1179
+ print(f"\n{out} exists; changes:\n{res['diff']}")
1180
+ ok = a.force
1181
+ if not ok and interactive and not a.yes:
1182
+ ok = inp(f"replace {out}? (a copy is kept as {out.name}.bak) [y/N] ").strip().lower() in ("y", "yes")
1183
+ if not ok:
1184
+ prop_f = out.with_name(out.name + ".proposed")
1185
+ prop_f.write_text(prop["toml"], encoding="utf-8")
1186
+ print(f"kept {out}; the proposal is in {prop_f} (use --force to replace)")
1187
+ return 1
1188
+ res = save(prop["toml"], out, overwrite=True)
1189
+ print(f"wrote {out} (old file kept as {res['backup']})")
1190
+ done()
1191
+ return 0
1192
+ save(prop["toml"], out)
1193
+ print(f"\nwrote {out}. Every ecadlib command run from this folder (or below it) now follows it; "
1194
+ f"`ecadlib rules check` shows the effective rules.")
1195
+ done()
1196
+ return 0
1197
+
1198
+
1199
+ def _abs(p, base):
1200
+ p = Path(str(p)).expanduser()
1201
+ return p if p.is_absolute() else base / p
1202
+
1203
+
1204
+ if __name__ == "__main__":
1205
+ sys.exit(main())