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/parts_export.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Parts table export for PLM / ERP imports: ecadlib export-parts plan.json --format csv|json|xlsx -o parts.csv
|
|
2
|
+
|
|
3
|
+
One row per library part in a librarian plan, with stable columns (COLUMNS; their names and order only ever grow at
|
|
4
|
+
the end, so an import mapping set up once keeps working). Parameters that have no column of their own go in
|
|
5
|
+
"Other Parameters" (CSV / XLSX: "name=value; ..." sorted by name; JSON: an object). Held parts are left out unless
|
|
6
|
+
--include-held. Cells that a spreadsheet would run as a formula get a leading apostrophe in CSV / XLSX (sheetsafe);
|
|
7
|
+
JSON keeps the plain text.
|
|
8
|
+
|
|
9
|
+
KiCad names ("KiCad Symbol", "KiCad Footprint") are the lib ids `ecadlib kicad-export --name LIB` writes
|
|
10
|
+
(LIB:<part>, LIB:<footprint>), the same ones `ecadlib kicad-dblib` puts in its database."""
|
|
11
|
+
import argparse, json, sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import sheetsafe as SS
|
|
15
|
+
|
|
16
|
+
FORMAT_VERSION = 1
|
|
17
|
+
COLUMNS = ["Part ID", "Status", "Category", "Folder", "Description", "Value", "Manufacturer", "Manufacturer Part Number",
|
|
18
|
+
"Datasheet", "Supplier 1", "Supplier Part Number 1", "Supplier 2", "Supplier Part Number 2",
|
|
19
|
+
"Lifecycle", "Symbol Ref", "Symbol File", "Footprint Ref", "Footprint File", "KiCad Symbol", "KiCad Footprint",
|
|
20
|
+
"3D Height (mm)", "Release Check", "Hold Reasons", "Sign-off", "Approved By", "Symbol Source", "Footprint Source",
|
|
21
|
+
"Other Parameters"]
|
|
22
|
+
PARAM_COLUMNS = {"Value": "Value", "Manufacturer": "Manufacturer", "Manufacturer Part Number": "Manufacturer Part Number",
|
|
23
|
+
"ComponentLink2URL": "Datasheet", "Datasheet": "Datasheet", "Supplier 1": "Supplier 1",
|
|
24
|
+
"Supplier Part Number 1": "Supplier Part Number 1", "Supplier 2": "Supplier 2",
|
|
25
|
+
"Supplier Part Number 2": "Supplier Part Number 2", "Lifecycle": "Lifecycle", "Lifecycle Status": "Lifecycle"}
|
|
26
|
+
SKIP_PARAMS = {"ComponentLink2Description"}
|
|
27
|
+
LIBRARY_STATUS = ("ready", "existing")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def kicad_ids(item, lib):
|
|
31
|
+
from .kicad_export import kname
|
|
32
|
+
fp = (item.get("footprint") or {})
|
|
33
|
+
has_fp = fp.get("ref") and (fp.get("file") or fp.get("kicad_mod"))
|
|
34
|
+
has_sym = (item.get("source") or {}).get("symbol") or (item.get("symbol") or {}).get("file")
|
|
35
|
+
return (f"{lib}:{kname(item['name'])}" if has_sym else "", f"{lib}:{kname(fp['ref'])}" if has_fp else "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _signoff(items, base):
|
|
39
|
+
"""{part: (sign-off, approved by)} from the approvals policy next to the plan; {} when there is none."""
|
|
40
|
+
from . import approvals as A
|
|
41
|
+
try:
|
|
42
|
+
policy = A.load_policy(base)
|
|
43
|
+
except Exception as e: # a broken policy file must not stop an export
|
|
44
|
+
print(f" warning: approvals policy not read: {e}", file=sys.stderr)
|
|
45
|
+
return {}
|
|
46
|
+
if not policy:
|
|
47
|
+
return {}
|
|
48
|
+
out = {}
|
|
49
|
+
for it in items:
|
|
50
|
+
try:
|
|
51
|
+
ev = A.evaluate(it, policy, base)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
out[it["name"]] = (f"unknown ({type(e).__name__})", "")
|
|
54
|
+
continue
|
|
55
|
+
state = "not required" if not ev["required"] else "approved" if not ev["missing"] and not ev["stale"] else "pending"
|
|
56
|
+
out[it["name"]] = (state, "; ".join(ev["approved_by"]))
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def rows(plan, lib="ecadlib", include_held=False):
|
|
61
|
+
"""Plan items -> [row dict with exactly COLUMNS], sorted by Part ID. plan: path to plan.json or a list of items."""
|
|
62
|
+
base = Path(plan).parent if not isinstance(plan, list) else Path(".")
|
|
63
|
+
items = json.loads(Path(plan).read_text(encoding="utf-8")) if not isinstance(plan, list) else plan
|
|
64
|
+
if not isinstance(items, list):
|
|
65
|
+
raise ValueError(f"{plan}: not a librarian plan (expected a list of parts)")
|
|
66
|
+
keep = [it for it in items if isinstance(it, dict) and it.get("name")
|
|
67
|
+
and (it.get("status", "ready") in LIBRARY_STATUS or (include_held and it.get("status") == "held"))]
|
|
68
|
+
sign = _signoff(keep, base) if not isinstance(plan, list) else {}
|
|
69
|
+
out = []
|
|
70
|
+
for it in sorted(keep, key=lambda i: str(i["name"])):
|
|
71
|
+
par = {str(p.get("name")): str(p.get("value", "")) for p in it.get("parameters", []) if str(p.get("value", "")).strip()}
|
|
72
|
+
r = dict.fromkeys(COLUMNS, "")
|
|
73
|
+
folder = str(it.get("folder") or "")
|
|
74
|
+
sym, fp = it.get("symbol") or {}, it.get("footprint") or {}
|
|
75
|
+
ks, kf = kicad_ids(it, lib)
|
|
76
|
+
rc = it.get("release_check") or {}
|
|
77
|
+
r.update({"Part ID": str(it["name"]), "Status": str(it.get("status", "ready")),
|
|
78
|
+
"Category": folder.rstrip("/").split("/")[-1] if folder else "", "Folder": folder,
|
|
79
|
+
"Description": str(it.get("description") or ""), "Symbol Ref": str(sym.get("ref") or ""),
|
|
80
|
+
"Symbol File": str(sym.get("file") or ""), "Footprint Ref": str(fp.get("ref") or ""),
|
|
81
|
+
"Footprint File": str(fp.get("file") or ""), "KiCad Symbol": ks, "KiCad Footprint": kf,
|
|
82
|
+
"3D Height (mm)": f"{float(rc['height']):.2f}" if isinstance(rc.get("height"), (int, float)) else "",
|
|
83
|
+
"Release Check": str(rc.get("result") or ""), "Hold Reasons": "; ".join(it.get("hold_reasons") or []),
|
|
84
|
+
"Symbol Source": str((it.get("source") or {}).get("symbol") or ""),
|
|
85
|
+
"Footprint Source": str((it.get("source") or {}).get("footprint") or "")})
|
|
86
|
+
r["Sign-off"], r["Approved By"] = sign.get(it["name"], ("", ""))
|
|
87
|
+
other = {}
|
|
88
|
+
for k in sorted(par):
|
|
89
|
+
col = PARAM_COLUMNS.get(k)
|
|
90
|
+
if col and not r[col]:
|
|
91
|
+
r[col] = par[k]
|
|
92
|
+
elif k not in SKIP_PARAMS and not col:
|
|
93
|
+
other[k] = par[k]
|
|
94
|
+
r["Other Parameters"] = other
|
|
95
|
+
out.append(r)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _flat(r):
|
|
100
|
+
return {k: ("; ".join(f"{a}={b}" for a, b in v.items()) if isinstance(v, dict) else v) for k, v in r.items()}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write(rs, out, fmt):
|
|
104
|
+
out = Path(out)
|
|
105
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
if fmt == "json":
|
|
107
|
+
out.write_text(json.dumps({"format": "ecadlib-parts", "version": FORMAT_VERSION, "columns": COLUMNS, "parts": rs},
|
|
108
|
+
indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
109
|
+
elif fmt == "csv":
|
|
110
|
+
with open(out, "w", newline="", encoding="utf-8-sig") as f: # utf-8-sig: Excel opens it as UTF-8
|
|
111
|
+
w = SS.DictWriter(f, fieldnames=COLUMNS)
|
|
112
|
+
w.writeheader()
|
|
113
|
+
w.writerows(_flat(r) for r in rs)
|
|
114
|
+
elif fmt == "xlsx":
|
|
115
|
+
from openpyxl import Workbook
|
|
116
|
+
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE # control characters (a \x0b from a datasheet) that
|
|
117
|
+
wb = Workbook() # XML can't hold: openpyxl refuses the whole file
|
|
118
|
+
ws = wb.active
|
|
119
|
+
ws.title = "Parts"
|
|
120
|
+
ws.append(COLUMNS)
|
|
121
|
+
for r in rs:
|
|
122
|
+
ws.append([SS.cell(ILLEGAL_CHARACTERS_RE.sub(" ", str(_flat(r)[c]))) for c in COLUMNS])
|
|
123
|
+
for row in ws.iter_rows(min_row=2):
|
|
124
|
+
for c in row:
|
|
125
|
+
c.data_type, c.number_format = "s", "@" # text: '0603' and '1e-7' stay as typed
|
|
126
|
+
ws.freeze_panes = "A2"
|
|
127
|
+
wb.save(out)
|
|
128
|
+
else:
|
|
129
|
+
raise ValueError(f"unknown format {fmt!r}: csv, json or xlsx")
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv=None):
|
|
134
|
+
ap = argparse.ArgumentParser(description="Export the library's parts table (stable columns) for PLM / ERP imports")
|
|
135
|
+
ap.add_argument("plan", help="librarian plan.json")
|
|
136
|
+
ap.add_argument("--format", choices=("csv", "json", "xlsx"), help="default: from the -o suffix, else csv")
|
|
137
|
+
ap.add_argument("-o", "--out", help="output file (default: parts.<format> next to the plan)")
|
|
138
|
+
ap.add_argument("--name", default="ecadlib", help="KiCad library name used by kicad-export (default %(default)s)")
|
|
139
|
+
ap.add_argument("--include-held", action="store_true", help="also export parts the release check held")
|
|
140
|
+
a = ap.parse_args(argv)
|
|
141
|
+
fmt = a.format or (Path(a.out).suffix.lower().lstrip(".") if a.out and Path(a.out).suffix.lower() in (".csv", ".json", ".xlsx") else "csv")
|
|
142
|
+
out = Path(a.out) if a.out else Path(a.plan).parent / f"parts.{fmt}"
|
|
143
|
+
try:
|
|
144
|
+
rs = rows(a.plan, a.name, a.include_held)
|
|
145
|
+
except (OSError, ValueError) as e:
|
|
146
|
+
print(f"cannot read plan: {e}", file=sys.stderr)
|
|
147
|
+
return 2
|
|
148
|
+
write(rs, out, fmt)
|
|
149
|
+
print(f"wrote {len(rs)} parts to {out} ({fmt})")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
sys.exit(main())
|
ecadlib/partview.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The library plan of one BOM as people see it: one card per part with its status in plain words (ready, held,
|
|
2
|
+
waiting for sign-off), each held reason as a sentence plus a next step, the cross-check and KiCad/Altium results with
|
|
3
|
+
both values when they disagree, the datasheet link (and page when known), what reading the datasheet cost and which
|
|
4
|
+
model read it, and a small footprint picture. Read-only; used by the web UI (Workspace.library_parts).
|
|
5
|
+
|
|
6
|
+
Held-reason wording comes from ecadlib.messages (the shared voice, docs/ui-voice.md)."""
|
|
7
|
+
import json, re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import messages as M
|
|
11
|
+
|
|
12
|
+
FIELD = {"pin_count": "pin count", "pitch": "pin pitch", "body_length": "body length", "body_width": "body width",
|
|
13
|
+
"lead_span": "lead span", "ep_length": "exposed pad length", "ep_width": "exposed pad width",
|
|
14
|
+
"has_ep": "exposed pad", "package_type": "package type", "pins": "pin numbers", "pin_names": "pin names"}
|
|
15
|
+
_LABEL_TO_FIELD = {"pin count": "pin_count", "pitch": "pitch", "body length": "body_length", "body width": "body_width",
|
|
16
|
+
"lead span": "lead_span", "exposed pad length": "ep_length", "exposed pad width": "ep_width",
|
|
17
|
+
"exposed pad": "has_ep", "package type": "package_type", "pin numbers": "pins", "pin names": "pin_names"}
|
|
18
|
+
_CC = re.compile(r"^(?:(?P<against>.*): )?(?P<label>[a-z ]+?) read (?P<first>.+?), (?P<src>table|second read) (?P<second>.+)$")
|
|
19
|
+
_PAR = re.compile(r"^(?P<what>.+?): KiCad (?P<kicad>.+?) / Altium (?P<altium>.+?)(?: \((?P<note>.*)\))?$")
|
|
20
|
+
SVG_MAX = 60_000 # a footprint picture bigger than this is left out of the list
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ------------------------------------------------------------------ held reasons in plain words
|
|
24
|
+
def reason(raw):
|
|
25
|
+
"""One hold reason -> {code, text, fix, detail} in the shared voice (ecadlib.messages); detail is the machine
|
|
26
|
+
wording, kept for reports and JSON."""
|
|
27
|
+
e = M.explain(raw)
|
|
28
|
+
return {"code": e["code"], "text": e["text"] + (f" ({e['note']})" if e.get("note") else ""), "fix": e["fix"], "detail": raw}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------ cross-check, parity, datasheet, cost
|
|
32
|
+
def _num(s):
|
|
33
|
+
m = re.match(r"\s*(-?\d+(?:\.\d+)?)", str(s))
|
|
34
|
+
return m and m.group(1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cross_check(rec, sidecar=None):
|
|
38
|
+
"""Plan item cross_check {status, summary} (+ the read's sidecar, when written) -> what the panel shows:
|
|
39
|
+
{status, label, rows: [{field, label, first, second, against, page}], summary}."""
|
|
40
|
+
if not rec:
|
|
41
|
+
return None
|
|
42
|
+
st = rec.get("status") or "unchecked"
|
|
43
|
+
label = {"agreed": "agrees with a second source", "disagreed": "two sources disagree",
|
|
44
|
+
"unchecked": "not cross-checked", "skipped": "not needed (footprint from another source)"}.get(st, st)
|
|
45
|
+
rows = []
|
|
46
|
+
full = (sidecar or {}).get("cross_check") or {}
|
|
47
|
+
dims = (sidecar or {}).get("dimensions") or {}
|
|
48
|
+
page = lambda f: (dims.get(f) or {}).get("page") if isinstance(dims.get(f), dict) else None
|
|
49
|
+
if full.get("checks"):
|
|
50
|
+
for c in full["checks"]:
|
|
51
|
+
for d in c.get("disagreed") or []:
|
|
52
|
+
rows.append({"field": d["field"], "label": FIELD.get(d["field"], d["field"]), "first": d["first"],
|
|
53
|
+
"second": d["second"], "against": c.get("against") or c.get("source") or "",
|
|
54
|
+
"second_is": "table" if c.get("source") == "standard table" else "second read",
|
|
55
|
+
"page": page(d["field"])})
|
|
56
|
+
else:
|
|
57
|
+
against = ""
|
|
58
|
+
for seg in (rec.get("summary") or "").split("; "):
|
|
59
|
+
m = _CC.match(seg)
|
|
60
|
+
if not m:
|
|
61
|
+
continue
|
|
62
|
+
against = m.group("against") or against
|
|
63
|
+
f = _LABEL_TO_FIELD.get(m.group("label").strip(), m.group("label").strip())
|
|
64
|
+
rows.append({"field": f, "label": FIELD.get(f, m.group("label")), "first": m.group("first"),
|
|
65
|
+
"second": m.group("second"), "against": against, "second_is": m.group("src"), "page": page(f)})
|
|
66
|
+
out = {"status": st, "label": label, "rows": rows, "summary": rec.get("summary") or ""}
|
|
67
|
+
if st == "disagreed" and rows:
|
|
68
|
+
r = rows[0]
|
|
69
|
+
text, fix = M.held("crosscheck.disagree", field=r["label"], first=r["first"], second=r["second"],
|
|
70
|
+
other=r["second_is"], page=r["page"])
|
|
71
|
+
out["reason"] = {"code": "crosscheck.disagree", "text": text, "fix": fix, "detail": "cross-check disagrees: " + out["summary"]}
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parity(item):
|
|
76
|
+
"""{result: match | mismatch | None, rows: [{what, kicad, altium}]} from the release check's parity rule."""
|
|
77
|
+
rc = item.get("release_check") or {}
|
|
78
|
+
res = rc.get("parity")
|
|
79
|
+
rows = []
|
|
80
|
+
for m in list(item.get("hold_reasons") or []) + [w for w in rc.get("warnings") or []]:
|
|
81
|
+
rule, _, msg = m.partition(": ")
|
|
82
|
+
if rule != "parity":
|
|
83
|
+
continue
|
|
84
|
+
p = _PAR.match(re.sub(r" \((cannot be overridden|not covered by --allow)\)$", "", msg))
|
|
85
|
+
rows.append({"what": p.group("what"), "kicad": p.group("kicad"), "altium": p.group("altium")} if p
|
|
86
|
+
else {"what": msg, "kicad": "", "altium": ""})
|
|
87
|
+
if rows and not res:
|
|
88
|
+
res = "mismatch"
|
|
89
|
+
return {"result": res, "label": {"match": "KiCad and Altium match", "mismatch": "KiCad and Altium differ"}.get(res, "not compared"),
|
|
90
|
+
"rows": rows}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def datasheet_url(item):
|
|
94
|
+
params = {p.get("name"): p.get("value") for p in item.get("parameters") or [] if isinstance(p, dict)}
|
|
95
|
+
for k, v in params.items():
|
|
96
|
+
m = re.fullmatch(r"ComponentLink(\d+)Description", k or "")
|
|
97
|
+
if m and str(v).strip().lower() == "datasheet":
|
|
98
|
+
u = params.get(f"ComponentLink{m.group(1)}URL")
|
|
99
|
+
if u:
|
|
100
|
+
return u
|
|
101
|
+
return params.get("Datasheet") or params.get("datasheet") or None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sidecar(out, name):
|
|
105
|
+
d = Path(out) / "datasheet"
|
|
106
|
+
if not d.is_dir():
|
|
107
|
+
return None
|
|
108
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", name)
|
|
109
|
+
for f in (d / f"{name}.extraction.json", d / f"{safe}.extraction.json"):
|
|
110
|
+
try:
|
|
111
|
+
if f.is_file() and f.resolve().parent == d.resolve():
|
|
112
|
+
v = json.loads(f.read_text(encoding="utf-8"))
|
|
113
|
+
return v if isinstance(v, dict) else None
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
return None
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def costs(out):
|
|
120
|
+
"""What the datasheet reads of this plan cost so far: totals and per part (from datasheet/cost.jsonl)."""
|
|
121
|
+
from . import ds_route as DR
|
|
122
|
+
recs = DR.read_ledger(Path(out) / "datasheet" / "cost.jsonl")
|
|
123
|
+
s = DR.summarize(recs)
|
|
124
|
+
return {"answers": s["answers"], "usd": round(s["usd"] + s["usd_estimated"], 4), "usd_estimated": s["usd_estimated"],
|
|
125
|
+
"models": sorted(m for m in s["by_model"] if m and m != "?"), "by_part": s["by_part"],
|
|
126
|
+
"tokens": s["input_tokens"] + s["output_tokens"]}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _picture(out, item):
|
|
130
|
+
"""Small top-view SVG of the footprint (from its .kicad_mod), or None."""
|
|
131
|
+
km = (item.get("footprint") or {}).get("kicad_mod")
|
|
132
|
+
if not km:
|
|
133
|
+
return None
|
|
134
|
+
f = (Path(out) / km)
|
|
135
|
+
try:
|
|
136
|
+
if not f.is_file() or Path(out).resolve() not in f.resolve().parents:
|
|
137
|
+
return None
|
|
138
|
+
from .app import footprint_svg
|
|
139
|
+
svg = footprint_svg(None, 12, f.read_text(encoding="utf-8"))
|
|
140
|
+
except Exception:
|
|
141
|
+
return None
|
|
142
|
+
return svg if len(svg) <= SVG_MAX else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _pads(svg):
|
|
146
|
+
return len(re.findall(r'class="pad[ "]', svg or ""))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _decisions(approvals):
|
|
150
|
+
"""Recorded decisions without an approvals.json policy: who approved last (a later reject takes it back)."""
|
|
151
|
+
last = {}
|
|
152
|
+
for a in approvals or []:
|
|
153
|
+
if isinstance(a, dict) and a.get("who"):
|
|
154
|
+
last[a.get("key") or a["who"]] = a
|
|
155
|
+
ok = [a["who"] for a in last.values() if a.get("decision") == "approve"]
|
|
156
|
+
no = [a["who"] for a in last.values() if a.get("decision") == "reject"]
|
|
157
|
+
if not ok and not no:
|
|
158
|
+
return None
|
|
159
|
+
return {"required": False, "met": None, "approved_by": ok, "rejected_by": no, "missing": [], "stale": []}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------ the cards
|
|
163
|
+
def cards(out, items, pictures=True):
|
|
164
|
+
"""out: the librarian folder of a BOM (plan.json, models/, datasheet/); items: the plan. -> {parts, counts, cost}."""
|
|
165
|
+
from . import librarian as L
|
|
166
|
+
cost = costs(out)
|
|
167
|
+
review = {r["part"]: r for r in L.review_rows(items)}
|
|
168
|
+
parts = []
|
|
169
|
+
for it in items:
|
|
170
|
+
name, st = it["name"], it.get("status") or ""
|
|
171
|
+
sc = _sidecar(out, name) if it.get("cross_check") else None
|
|
172
|
+
cc = cross_check(it.get("cross_check"), sc)
|
|
173
|
+
reasons = []
|
|
174
|
+
for h in it.get("hold_reasons") or []:
|
|
175
|
+
if cc and cc.get("reason") and h.startswith("cross-check"): # said once, with both values
|
|
176
|
+
continue
|
|
177
|
+
reasons.append(reason(h))
|
|
178
|
+
if cc and cc["status"] == "disagreed" and cc.get("reason"):
|
|
179
|
+
reasons.insert(0, cc["reason"])
|
|
180
|
+
elif st == "held" and not reasons:
|
|
181
|
+
reasons = [reason(x) for x in (review.get(name, {}).get("why") or "").split("; ") if x][:3]
|
|
182
|
+
appr = it.get("approval") or None
|
|
183
|
+
if st == "awaiting_approval" and appr:
|
|
184
|
+
for m in appr.get("missing") or []:
|
|
185
|
+
reasons.append(reason(f"awaiting approval: {m}"))
|
|
186
|
+
pc = cost["by_part"].get(name) or {}
|
|
187
|
+
svg = _picture(out, it) if pictures else None
|
|
188
|
+
url = datasheet_url(it)
|
|
189
|
+
page = next((r["page"] for r in (cc or {}).get("rows", []) if r.get("page")), None) or \
|
|
190
|
+
((sc or {}).get("pin_table") or {}).get("page")
|
|
191
|
+
parts.append({
|
|
192
|
+
"part": name, "folder": it.get("folder") or "", "description": it.get("description") or "",
|
|
193
|
+
"status": st, "status_label": M.status_word(st),
|
|
194
|
+
"allowed": bool(it.get("allow")), "duplicate_of": it.get("duplicate_of"),
|
|
195
|
+
"reasons": reasons,
|
|
196
|
+
"symbol": {"ref": (it.get("symbol") or {}).get("ref"), "found": bool((it.get("symbol") or {}).get("file")),
|
|
197
|
+
"from": (it.get("source") or {}).get("symbol") or ""},
|
|
198
|
+
"footprint": {"ref": (it.get("footprint") or {}).get("ref"), "found": bool((it.get("footprint") or {}).get("file")),
|
|
199
|
+
"from": (it.get("source") or {}).get("footprint") or "", "pads": _pads(svg) or None},
|
|
200
|
+
"picture": svg,
|
|
201
|
+
"cross_check": cc, "parity": parity(it),
|
|
202
|
+
"datasheet": {"url": url, "page": page} if url else None,
|
|
203
|
+
"cost": {"usd": round(pc.get("usd", 0.0), 4), "models": [m for m in pc.get("models") or [] if m],
|
|
204
|
+
"answers": pc.get("answers", 0)} if pc else None,
|
|
205
|
+
"approval": {k: appr.get(k) for k in ("required", "met", "approved_by", "missing", "stale")} if appr
|
|
206
|
+
else _decisions(it.get("approvals")),
|
|
207
|
+
"tried": (it.get("librarian") or {}).get("tried") or [],
|
|
208
|
+
})
|
|
209
|
+
counts = {"parts": len(parts), "ready": sum(p["status"] == "ready" for p in parts),
|
|
210
|
+
"held": sum(p["status"] == "held" for p in parts),
|
|
211
|
+
"waiting": sum(p["status"] == "awaiting_approval" for p in parts),
|
|
212
|
+
"cross_check_disagreed": sum(bool(p["cross_check"]) and p["cross_check"]["status"] == "disagreed" for p in parts),
|
|
213
|
+
"parity_mismatch": sum(p["parity"]["result"] == "mismatch" for p in parts)}
|
|
214
|
+
return {"parts": parts, "counts": counts,
|
|
215
|
+
"cost": {k: cost[k] for k in ("answers", "usd", "usd_estimated", "models", "tokens")}}
|
ecadlib/plugins.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Plugins: third-party part sources and exporters, installed as ordinary Python packages and found through entry points.
|
|
2
|
+
|
|
3
|
+
ecadlib plugins list built-in and installed providers and exporters (and load errors)
|
|
4
|
+
ecadlib plugins export NAME PLAN.json -o DIR run an exporter plugin on a librarian plan
|
|
5
|
+
|
|
6
|
+
Part sources (entry point group `ecadlib.providers`): the object is a class like the built-in ones in
|
|
7
|
+
ecadlib/providers.py:
|
|
8
|
+
|
|
9
|
+
class MyDistributor(ecadlib.providers.Provider):
|
|
10
|
+
name = "mydist" # what [providers] order and --providers call it
|
|
11
|
+
auth = "API key in MYDIST_KEY" # shown in reports and the UI
|
|
12
|
+
env = ("MYDIST_KEY",) # configured() is True when these are set
|
|
13
|
+
def lookup(self, line): # line: the BOM line dict (mpn, manufacturer, lcsc, value, footprint, ...)
|
|
14
|
+
r = self.http.get(...) # self.http: ecadlib's cached, rate-limited HTTP client
|
|
15
|
+
return SourceResult(self.name, found=True, mpn=..., has_symbol=True, has_footprint=True, url=...)
|
|
16
|
+
|
|
17
|
+
raise NotConfigured when credentials are missing; any other exception from a plugin becomes a note on that BOM line
|
|
18
|
+
(a broken plugin never stops a triage). Triage asks the providers in order and stops at the first one that has a
|
|
19
|
+
footprint.
|
|
20
|
+
|
|
21
|
+
Exporters (entry point group `ecadlib.exporters`): the object is a callable, or an object with an `export` method,
|
|
22
|
+
called as export(items, out_dir, rules) -> list of written paths. items: the plan items (plan.json), out_dir: a Path
|
|
23
|
+
that exists, rules: the loaded library rules. Optional attributes: name, description.
|
|
24
|
+
|
|
25
|
+
[providers] order = ["mydist", "jlcpcb_search", "easyeda"] chooses and orders the sources for triage, the librarian,
|
|
26
|
+
the web UI / MCP workspace and the library agent; [] (the default) keeps each command's own default. An explicit
|
|
27
|
+
--providers flag still wins. See examples/ecadlib_example_plugin/ for a complete, installable example."""
|
|
28
|
+
import argparse, json, sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from . import providers as P
|
|
32
|
+
|
|
33
|
+
GROUPS = ("ecadlib.providers", "ecadlib.exporters")
|
|
34
|
+
_cache = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _entry_points(group):
|
|
38
|
+
from importlib.metadata import entry_points
|
|
39
|
+
try:
|
|
40
|
+
return list(entry_points(group=group))
|
|
41
|
+
except TypeError: # very old importlib.metadata: a dict of groups
|
|
42
|
+
return list(entry_points().get(group, []))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load(group, eps=None):
|
|
46
|
+
"""{name: object} for one group, and [(name, error)] for plugins that failed to load. eps: entry points (tests)."""
|
|
47
|
+
if eps is None and group in _cache:
|
|
48
|
+
return _cache[group]
|
|
49
|
+
found, errors = {}, []
|
|
50
|
+
for ep in (_entry_points(group) if eps is None else eps):
|
|
51
|
+
try:
|
|
52
|
+
obj = ep.load()
|
|
53
|
+
except Exception as e: # a broken plugin is reported, never fatal
|
|
54
|
+
errors.append((ep.name, f"{type(e).__name__}: {e}"))
|
|
55
|
+
continue
|
|
56
|
+
found[ep.name] = obj
|
|
57
|
+
res = (found, errors)
|
|
58
|
+
if eps is None:
|
|
59
|
+
_cache[group] = res
|
|
60
|
+
return res
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def reset():
|
|
64
|
+
_cache.clear()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def provider_classes(eps=None):
|
|
68
|
+
"""{name: class}: the built-in providers, then plugins (a plugin cannot replace a built-in name). -> (dict, errors)"""
|
|
69
|
+
out = {c.name: c for c in P.ALL}
|
|
70
|
+
plugins, errors = load("ecadlib.providers", eps)
|
|
71
|
+
errors = list(errors)
|
|
72
|
+
for ep_name, cls in plugins.items():
|
|
73
|
+
name = getattr(cls, "name", None) or ep_name
|
|
74
|
+
if not isinstance(cls, type) or not callable(getattr(cls, "lookup", None)):
|
|
75
|
+
errors.append((ep_name, "not a provider class (needs name, lookup(line))"))
|
|
76
|
+
continue
|
|
77
|
+
if name in out:
|
|
78
|
+
errors.append((ep_name, f"provider name {name!r} is taken by a built-in or another plugin"))
|
|
79
|
+
continue
|
|
80
|
+
out[name] = _guarded(cls, name)
|
|
81
|
+
return out, errors
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _guarded(cls, name):
|
|
85
|
+
"""A subclass of plugin class cls whose construction and configured() never raise: a plugin that fails to start
|
|
86
|
+
(missing config file, bad key ...) answers every lookup with an error note on the line instead of stopping triage,
|
|
87
|
+
and reports itself as not configured. lookup() errors are turned into notes by bom_triage (_plugin = True)."""
|
|
88
|
+
def __init__(self, *a, **k):
|
|
89
|
+
self._plugin_error = None
|
|
90
|
+
try:
|
|
91
|
+
cls.__init__(self, *a, **k)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
self._plugin_error = f"{type(e).__name__}: {str(e)[:120]}"
|
|
94
|
+
self.http = a[0] if a else k.get("http")
|
|
95
|
+
|
|
96
|
+
def lookup(self, line):
|
|
97
|
+
if self._plugin_error:
|
|
98
|
+
raise RuntimeError(f"plugin failed to start: {self._plugin_error}")
|
|
99
|
+
return cls.lookup(self, line)
|
|
100
|
+
|
|
101
|
+
def configured(self):
|
|
102
|
+
if self._plugin_error:
|
|
103
|
+
return False
|
|
104
|
+
try:
|
|
105
|
+
return bool(cls.configured(self)) if callable(getattr(cls, "configured", None)) else True
|
|
106
|
+
except Exception:
|
|
107
|
+
return False
|
|
108
|
+
return type(cls.__name__, (cls,), {"name": name, "_plugin": True, "auth": str(getattr(cls, "auth", "") or ""),
|
|
109
|
+
"__module__": cls.__module__, "__init__": __init__, "lookup": lookup,
|
|
110
|
+
"configured": configured})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def select(names=None, rules=None, default=None, eps=None):
|
|
114
|
+
"""Provider classes in order. names: an explicit choice (list or comma text, e.g. --providers) wins; else the
|
|
115
|
+
rules' [providers] order when not empty; else default (names; None = every built-in). Unknown names raise
|
|
116
|
+
ValueError listing what is available; a plugin named in [providers] order that fails to load is skipped with a
|
|
117
|
+
warning on stderr (an explicit --providers choice of it raises)."""
|
|
118
|
+
if isinstance(names, str):
|
|
119
|
+
names = [n.strip() for n in names.split(",") if n.strip()]
|
|
120
|
+
order = ((rules or {}).get("providers") or {}).get("order") or []
|
|
121
|
+
want = list(names) if names else list(order) if order else (
|
|
122
|
+
[n.strip() for n in default.split(",") if n.strip()] if isinstance(default, str) else
|
|
123
|
+
list(default) if default is not None else [c.name for c in P.ALL])
|
|
124
|
+
avail, errors = provider_classes(eps)
|
|
125
|
+
if not names and order: # a plugin in the rules' order that fails to load is skipped
|
|
126
|
+
broken = {n: e for n, e in errors if n in want and n not in avail} # (with a warning): triage goes on
|
|
127
|
+
for n, e in broken.items():
|
|
128
|
+
print(f"ecadlib: [providers] order: plugin {n} skipped, it failed to load: {e}", file=sys.stderr)
|
|
129
|
+
want = [n for n in want if n not in broken]
|
|
130
|
+
bad = [n for n in want if n not in avail]
|
|
131
|
+
if bad:
|
|
132
|
+
why = "".join(f"; plugin {n} failed to load: {e}" for n, e in errors if n in bad)
|
|
133
|
+
raise ValueError(f"unknown provider {bad[0]!r}; available: {', '.join(avail)}{why}")
|
|
134
|
+
if "jlcpcb_search" in want and "easyeda" in want and want.index("jlcpcb_search") > want.index("easyeda"):
|
|
135
|
+
want.remove("jlcpcb_search") # it finds the LCSC number EasyEDA is keyed by: always before it
|
|
136
|
+
want.insert(want.index("easyeda"), "jlcpcb_search")
|
|
137
|
+
seen, out = set(), []
|
|
138
|
+
for n in want:
|
|
139
|
+
if n not in seen:
|
|
140
|
+
seen.add(n)
|
|
141
|
+
out.append(avail[n])
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def order_set(rules):
|
|
146
|
+
return bool(((rules or {}).get("providers") or {}).get("order"))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def exporters(eps=None):
|
|
150
|
+
"""{name: callable(items, out_dir, rules)} and load errors."""
|
|
151
|
+
found, errors = load("ecadlib.exporters", eps)
|
|
152
|
+
out, errors = {}, list(errors)
|
|
153
|
+
for name, obj in found.items():
|
|
154
|
+
try:
|
|
155
|
+
target = obj() if isinstance(obj, type) else obj # a class: one instance with an export method
|
|
156
|
+
except Exception as e:
|
|
157
|
+
errors.append((name, f"{type(e).__name__}: {e}"))
|
|
158
|
+
continue
|
|
159
|
+
fn = getattr(target, "export", None) or (target if callable(target) else None)
|
|
160
|
+
if not callable(fn):
|
|
161
|
+
errors.append((name, "not an exporter (needs export(items, out_dir, rules))"))
|
|
162
|
+
continue
|
|
163
|
+
desc = getattr(target, "description", "") or (fn.__doc__ or "").strip().split("\n")[0]
|
|
164
|
+
out[getattr(target, "name", None) or name] = (fn, desc)
|
|
165
|
+
return out, errors
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def run_exporter(name, plan, out_dir, rules=None, eps=None):
|
|
169
|
+
"""Run exporter `name` on a plan (path or list of items). -> list of written paths (as text)."""
|
|
170
|
+
from . import rules as RU
|
|
171
|
+
ex, errors = exporters(eps)
|
|
172
|
+
if name not in ex:
|
|
173
|
+
why = "".join(f"; {n} failed to load: {e}" for n, e in errors if n == name)
|
|
174
|
+
raise ValueError(f"unknown exporter {name!r}; installed: {', '.join(ex) or 'none'}{why}")
|
|
175
|
+
items = json.loads(Path(plan).read_text()) if not isinstance(plan, list) else plan
|
|
176
|
+
if not isinstance(items, list):
|
|
177
|
+
raise ValueError(f"{plan}: not a plan (a JSON list of parts)")
|
|
178
|
+
out_dir = Path(out_dir)
|
|
179
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
rules = RU.resolve(rules, Path(plan).parent if not isinstance(plan, list) else out_dir)
|
|
181
|
+
paths = ex[name][0](items, out_dir, rules) or []
|
|
182
|
+
return [str(p) for p in paths]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def describe(eps_providers=None, eps_exporters=None):
|
|
186
|
+
provs, perr = provider_classes(eps_providers)
|
|
187
|
+
ex, eerr = exporters(eps_exporters)
|
|
188
|
+
return {"providers": [{"name": n, "plugin": bool(getattr(c, "_plugin", False)), "auth": getattr(c, "auth", ""),
|
|
189
|
+
"module": c.__module__} for n, c in provs.items()],
|
|
190
|
+
"exporters": [{"name": n, "description": d} for n, (_, d) in ex.items()],
|
|
191
|
+
"errors": [{"plugin": n, "error": e} for n, e in perr + eerr]}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv=None):
|
|
195
|
+
ap = argparse.ArgumentParser(prog="ecadlib plugins", description="installed part-source and exporter plugins")
|
|
196
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
197
|
+
ls = sub.add_parser("list", help="providers and exporters, built-in and installed")
|
|
198
|
+
ls.add_argument("--json", action="store_true")
|
|
199
|
+
ex = sub.add_parser("export", help="run an exporter plugin on a librarian plan")
|
|
200
|
+
ex.add_argument("name")
|
|
201
|
+
ex.add_argument("plan")
|
|
202
|
+
ex.add_argument("-o", "--out", required=True, help="output folder")
|
|
203
|
+
ex.add_argument("--rules", help="ecadlib.toml (default: found from the plan's folder)")
|
|
204
|
+
a = ap.parse_args(argv)
|
|
205
|
+
if a.cmd == "list":
|
|
206
|
+
d = describe()
|
|
207
|
+
if a.json:
|
|
208
|
+
print(json.dumps(d, indent=2))
|
|
209
|
+
return 0
|
|
210
|
+
print("providers (triage part sources; [providers] order picks and orders them):")
|
|
211
|
+
for p in d["providers"]:
|
|
212
|
+
print(f" {p['name']:<16} {'plugin ' + p['module'] if p['plugin'] else 'built-in':<34} {p['auth'][:60]}")
|
|
213
|
+
print("exporters (ecadlib plugins export NAME PLAN -o DIR):")
|
|
214
|
+
for e in d["exporters"] or [{"name": "(none installed)", "description": ""}]:
|
|
215
|
+
print(f" {e['name']:<16} {e['description']}")
|
|
216
|
+
for e in d["errors"]:
|
|
217
|
+
print(f" plugin {e['plugin']} failed to load: {e['error']}", file=sys.stderr)
|
|
218
|
+
return 0
|
|
219
|
+
try:
|
|
220
|
+
paths = run_exporter(a.name, a.plan, a.out, a.rules)
|
|
221
|
+
except (ValueError, OSError) as e:
|
|
222
|
+
print(f"ecadlib plugins export: {e}", file=sys.stderr)
|
|
223
|
+
return 2
|
|
224
|
+
for p in paths:
|
|
225
|
+
print(p)
|
|
226
|
+
print(f"{a.name}: {len(paths)} file(s) -> {a.out}")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
sys.exit(main())
|