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