ecadlib 0.3.0__py3-none-any.whl

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