ecadlib 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/firstrun.py ADDED
@@ -0,0 +1,400 @@
1
+ """First-run conveniences for a new user (used by `ecadlib init`, `ecadlib demo` and ecadlib/cli.py):
2
+
3
+ - setup questions for `ecadlib init`: which tool (Altium / KiCad / both), your library folders, the workspace folder,
4
+ which part sources to use, and which API keys are set (variable NAMES only, never values). The answers go in
5
+ ecadlib.toml [setup] (and [sources] order); `--yes` takes the defaults without asking. Afterwards `init` runs the
6
+ doctor and prints the next commands to run.
7
+ - `ecadlib demo`: a small library built from a bundled sample BOM, fully offline (standard parts only, no keys), in a
8
+ real workspace folder, so `ecadlib status` and `ecadlib ui` can open it.
9
+ - checks the CLI runs before a command: --lib folders that do not exist and output folders that cannot be written
10
+ give one line and a fix instead of a traceback or a silent empty result; [setup] libs / workspace fill in --lib /
11
+ --workspace when neither the command line nor ECADLIB_LIB / ECADLIB_WORKSPACE give them.
12
+ - `blockers(rows)`: after a librarian run, one line per thing that held parts back for a reason outside the part
13
+ (network blocked, no API key for the datasheet reader), with the fix."""
14
+ import argparse, json, os, re, shutil, sys, tempfile, time
15
+ from pathlib import Path
16
+
17
+ from . import rules as RU
18
+
19
+ TOOLS = ("altium", "kicad", "both")
20
+ DEMO_BOM = Path(__file__).with_name("assets") / "demo_bom.csv"
21
+ DEMO_NAME = "ecadlib_demo"
22
+ DEMO_HEADER = "# ecadlib demo workspace (written by `ecadlib demo`)"
23
+
24
+
25
+ # ------------------------------------------------------------------ setup questions (ecadlib init)
26
+ def key_status(rules=None, environ=None):
27
+ """[(env var name, set?, what it unlocks)] - names only; values are never read beyond 'is it non-empty'."""
28
+ from .doctor import key_services
29
+ environ = os.environ if environ is None else environ
30
+ out = []
31
+ for name, env, why in key_services(rules):
32
+ ok = all(environ.get(e) for e in env)
33
+ if name == "altium365" and not ok and environ.get("NEXAR_CLIENT_ID") and environ.get("NEXAR_CLIENT_SECRET"):
34
+ env, ok = ["NEXAR_CLIENT_ID", "NEXAR_CLIENT_SECRET"], True
35
+ out.append((" + ".join(env), ok, why))
36
+ return out
37
+
38
+
39
+ def _split(text):
40
+ return [p.strip() for p in re.split(r"[,;]", text or "") if p.strip()]
41
+
42
+
43
+ def _yes(ans, default=True):
44
+ ans = (ans or "").strip().lower()
45
+ return default if not ans else ans in ("y", "yes", "1", "true")
46
+
47
+
48
+ def ask_setup(existing=None, given=None, inp=input, out=print, interactive=True, rules=None, environ=None):
49
+ """The [setup] answers {tool, libs, workspace, sources}. existing: the current [setup]/[sources] of ecadlib.toml
50
+ (its values are the defaults); given: values from the command line (never asked). interactive=False: no questions,
51
+ defaults only. sources is the [sources] order to write, or None to leave it as it is."""
52
+ existing, given = existing or {}, {k: v for k, v in (given or {}).items() if v not in (None, [], "")}
53
+ st = dict(existing.get("setup") or {})
54
+ cur_sources = list((existing.get("sources") or {}).get("order") or RU.DEFAULTS["sources"]["order"])
55
+ ans = {"tool": given.get("tool") or st.get("tool") or "both",
56
+ "libs": list(given.get("libs") or st.get("libs") or []),
57
+ "workspace": given.get("workspace") or st.get("workspace") or "ecadlib_workspace",
58
+ "sources": list(given["sources"]) if given.get("sources") else None}
59
+ keys = key_status(rules, environ)
60
+ ds_env = ((rules or {}).get("datasheet") or {}).get("api_key_env") or "ANTHROPIC_API_KEY"
61
+ ds_key = next((ok for n, ok, _ in keys if n == ds_env), False)
62
+ if interactive:
63
+ out("\nSetup (Enter keeps the value in [brackets]):")
64
+ if "tool" not in given:
65
+ for _ in range(3):
66
+ a = inp(f" Which tool do you make libraries for? altium / kicad / both [{ans['tool']}]: ").strip().lower()
67
+ if not a or a in TOOLS:
68
+ ans["tool"] = a or ans["tool"]
69
+ break
70
+ out(" please type altium, kicad or both")
71
+ else:
72
+ out(f" keeping {ans['tool']}")
73
+ if "libs" not in given:
74
+ a = inp(" Your existing library folders (KiCad .kicad_sym/.pretty or Altium .SchLib/.PcbLib), separated by commas"
75
+ f" [{', '.join(ans['libs']) or 'none'}]: ").strip()
76
+ if a:
77
+ ans["libs"] = [] if a.lower() == "none" else _split(a)
78
+ for l in ans["libs"]:
79
+ if not Path(l).expanduser().exists():
80
+ out(f" note: {l} does not exist yet; check the path (the setup check will flag it too)")
81
+ if "workspace" not in given:
82
+ a = inp(f" Folder where ecadlib keeps your BOMs and the libraries it builds [{ans['workspace']}]: ").strip()
83
+ ans["workspace"] = a or ans["workspace"]
84
+ if ans["sources"] is None:
85
+ online = _yes(inp(" Look missing parts up online (KiCad library on gitlab.com, EasyEDA/LCSC)? [Y/n]: "))
86
+ ds = _yes(inp(f" Let a model read the datasheet for parts no library has? (needs {ds_env}, which is "
87
+ f"{'set' if ds_key else 'not set yet'}) [{'Y/n' if ds_key else 'y/N'}]: "), default=ds_key)
88
+ order = [s for s in cur_sources if (online or s not in ("kicad", "easyeda")) and (ds or s != "datasheet")]
89
+ if "standard" not in order:
90
+ order.append("standard")
91
+ ans["sources"] = order if order != cur_sources else None
92
+ return ans
93
+
94
+
95
+ def setup_raw(ans):
96
+ """The rules-file sections for the answers (added to what `init` infers)."""
97
+ raw = {"setup": {"tool": ans["tool"], "libs": [str(l) for l in ans["libs"]], "workspace": str(ans["workspace"])}}
98
+ if ans.get("sources"):
99
+ raw["sources"] = {"order": list(ans["sources"])}
100
+ return raw
101
+
102
+
103
+ def keys_lines(rules=None, environ=None):
104
+ """One line on the API keys that matter for a first run (names only); the doctor lists every one."""
105
+ main = [k for k in key_status(rules, environ) if not k[2].endswith("part lookups")]
106
+ have = [n for n, ok, _ in main if ok]
107
+ miss = [f"{n} ({why.split(',')[0]})" for n, ok, why in main if not ok]
108
+ return ["API keys (names only; `ecadlib keys set NAME`, the setup page or your environment, never ecadlib.toml): "
109
+ + "; ".join(x for x in (f"set: {', '.join(have)}" if have else "", f"not set: {', '.join(miss)}" if miss else "") if x)
110
+ + ". None is needed for KiCad-library and standard parts."]
111
+
112
+
113
+ def next_steps(ans, rules_file="ecadlib.toml"):
114
+ """[(command, what it does)]: the 2-3 commands to run after `ecadlib init`."""
115
+ ws = ans.get("workspace") or "ecadlib_workspace"
116
+ lib = f"{ws}/librarian/YOUR_BOM" # <workspace>/librarian/*/plan.json: `ecadlib status` lists it
117
+ steps = [("ecadlib demo", "see a real result first: a small library from a bundled BOM, offline, in under a minute"),
118
+ (f"ecadlib librarian YOUR_BOM.csv -o {lib}", "fill every missing symbol and footprint for your BOM, then the "
119
+ "release check (`ecadlib status` shows the result)")]
120
+ if ans.get("tool") in ("kicad", "both"):
121
+ steps.append((f"ecadlib kicad-export {lib}/plan.json -o {ws}/kicad", "the library as KiCad files with fp-lib-table / sym-lib-table"))
122
+ if ans.get("tool") in ("altium", "both"):
123
+ steps.append(("ecadlib ui", f"open the web UI on {ws}: review held parts, sign off, send to Altium (DbLib, .SchLib/.PcbLib, Altium 365)"))
124
+ return steps
125
+
126
+
127
+ def finish_init(ans, rules_file, out=print, environ=None, run_doctor=True):
128
+ """After `init` wrote the file: make the workspace folder, run the doctor, print the next commands. Returns the
129
+ doctor report (None when not run)."""
130
+ from . import doctor
131
+ ws = Path(ans["workspace"]).expanduser()
132
+ if not ws.is_absolute():
133
+ ws = Path(rules_file).resolve().parent / ws
134
+ try:
135
+ ws.mkdir(parents=True, exist_ok=True)
136
+ except OSError as e:
137
+ out(f"could not create the workspace {ws}: {e.strerror or e}")
138
+ rules = RU.load(str(rules_file))
139
+ for line in keys_lines(rules, environ):
140
+ out(line)
141
+ rep = None
142
+ if run_doctor:
143
+ libs = [str(_rel_to(l, rules_file)) for l in ans["libs"]]
144
+ rep = doctor.run(str(ws), libs, str(rules_file), environ)
145
+ out("\nChecking the setup (ecadlib doctor):")
146
+ from . import term as TM # styled on a terminal; plain when captured (web UI, tests)
147
+ out(doctor.render(rep, TM.wants_style(sys.stdout), TM.term_width(sys.stdout), TM.VERBOSITY) if out is print
148
+ else doctor.render(rep))
149
+ out("\nNext:")
150
+ for c, why in next_steps(ans):
151
+ out(f" {c}\n {why}")
152
+ return rep
153
+
154
+
155
+ def _rel_to(p, rules_file):
156
+ p = Path(str(p)).expanduser()
157
+ return p if p.is_absolute() else (Path(rules_file).resolve().parent / p)
158
+
159
+
160
+ # ------------------------------------------------------------------ [setup] defaults for --lib / --workspace
161
+ def config_defaults(cmd, argv, environ=None, start="."):
162
+ """argv with --lib / --workspace from ecadlib.toml [setup] added where the command takes them (the same commands
163
+ as ECADLIB_LIB / ECADLIB_WORKSPACE) and neither the command line nor the environment gives them. Relative paths
164
+ in [setup] start at the rules file's folder. Returns (argv, notes). Never raises: a bad file is left to the command."""
165
+ from .cliutil import ENV_FLAGS, _has_flag, _subcommand
166
+ environ = os.environ if environ is None else environ
167
+ argv, notes = list(argv), []
168
+ if any(a in ("-h", "--help") for a in argv):
169
+ return argv, notes
170
+ wanted = [(var, flag, cmds[cmd]) for var, (flag, cmds) in ENV_FLAGS.items()
171
+ if cmd in cmds and not (environ.get(var) or "").strip() and not _has_flag(argv, flag)]
172
+ if not wanted:
173
+ return argv, notes
174
+ try:
175
+ f = RU.find(start)
176
+ if not f:
177
+ return argv, notes
178
+ raw = RU.parse_text(f.read_text(encoding="utf-8"), f.name)
179
+ st = raw.get("setup") or {}
180
+ except Exception:
181
+ return argv, notes
182
+ for var, flag, subs in wanted:
183
+ vals = st.get("libs") if flag == "--lib" else [st.get("workspace")] if st.get("workspace") else []
184
+ vals = [v for v in (vals or []) if isinstance(v, str) and v.strip()]
185
+ if not vals:
186
+ continue
187
+ extra = [x for v in vals for x in (flag, str(_rel_to(v, f)))]
188
+ if subs is None:
189
+ argv = extra + argv
190
+ elif _subcommand(argv) in subs:
191
+ argv = argv + extra
192
+ else:
193
+ continue
194
+ notes.append(f"{flag} from [setup] in {f}")
195
+ return argv, notes
196
+
197
+
198
+ # ------------------------------------------------------------------ checks before a command runs
199
+ LIB_CHECKED = {"triage", "librarian", "resolve", "kicad-export", "eagle-export", "easyeda-export", "mcp", "ui", "agent-watch",
200
+ "agent", "dedupe"}
201
+ OUT_CHECKED = {"init", "demo", "triage", "librarian", "kicad-export", "eagle-export", "easyeda-export", "kicad-dblib", "dblib",
202
+ "export-parts", "dblib-sync"}
203
+
204
+
205
+ def _values(argv, flags):
206
+ out, it = [], iter(argv)
207
+ for a in it:
208
+ if a == "--":
209
+ break
210
+ for f in flags:
211
+ if a == f:
212
+ v = next(it, None)
213
+ if v is not None:
214
+ out.append(v)
215
+ elif f.startswith("--") and a.startswith(f + "="):
216
+ out.append(a.split("=", 1)[1])
217
+ elif not f.startswith("--") and a.startswith(f) and len(a) > len(f) and not a.startswith("--"):
218
+ out.append(a[len(f):])
219
+ return out
220
+
221
+
222
+ def writable_problem(path):
223
+ """None when `path` (a file or folder to be created) can be written, else a short reason."""
224
+ p = Path(path).expanduser()
225
+ q = p
226
+ while not q.exists():
227
+ if q.parent == q:
228
+ break
229
+ q = q.parent
230
+ if q.exists() and not q.is_dir():
231
+ if q == p:
232
+ q = q.parent
233
+ else:
234
+ return f"{q} is a file, not a folder"
235
+ try:
236
+ with tempfile.TemporaryFile(dir=q, prefix=".ecadlib-probe-"):
237
+ pass
238
+ except OSError as e:
239
+ return f"cannot write in {q} ({e.strerror or e})"
240
+ return None
241
+
242
+
243
+ def preflight(cmd, argv, environ=None):
244
+ """(message, hint) for a command line that cannot work (a --lib folder that does not exist, an output folder that
245
+ cannot be written), or None. Runs before the command, so a typo gives one line instead of a silent empty result."""
246
+ if any(a in ("-h", "--help") for a in argv):
247
+ return None
248
+ if cmd in LIB_CHECKED:
249
+ for l in _values(argv, ["--lib"]):
250
+ if not Path(l).expanduser().exists():
251
+ return (f"library folder not found: {l}",
252
+ "fix --lib (or ECADLIB_LIB, or [setup] libs in ecadlib.toml); `ecadlib doctor` checks every path")
253
+ if cmd in OUT_CHECKED:
254
+ flags = ["--to"] if cmd == "dblib-sync" else ["-o", "--out"]
255
+ for o in _values(argv, flags + (["--out"] if cmd == "dblib-sync" else [])):
256
+ why = writable_problem(o)
257
+ if why:
258
+ return (f"cannot write the output {o}: {why}", "pick another -o / --out folder you can write to")
259
+ return None
260
+
261
+
262
+ # ------------------------------------------------------------------ after a librarian run
263
+ def bom_hint(err):
264
+ """A fix line for a BOM the reader could not use ('' when there is nothing to add)."""
265
+ t = str(err)
266
+ if "no BOM header found" in t:
267
+ return ("\n hint: name a column MPN (or Manufacturer Part Number), Designator or Value; or map your headers in "
268
+ "ecadlib.toml, e.g. [bom.columns] mpn = \"Part No\" (`ecadlib triage BOM` shows what was recognised)")
269
+ if "the BOM file is empty" in t or "no part rows" in t:
270
+ return "\n hint: export the BOM again with its part rows (one row per part or per group of designators)"
271
+ return ""
272
+
273
+
274
+ NET_RE = re.compile(r"(ProxyError|ConnectionError|ConnectTimeout|ReadTimeout|timed out|Name or service not known|"
275
+ r"Temporary failure in name resolution|proxy CONNECT refused|HTTP 403|HTTP 407|HTTP 429|"
276
+ r"Max retries exceeded|offline and not (?:cached|downloaded))", re.I)
277
+
278
+
279
+ def blockers(rows, rules=None, offline=False):
280
+ """[(message, fix)] for causes outside the parts that held them back: part sources that could not be reached, and
281
+ the datasheet reader without an API key. rows: librarian.review_rows(items) (each has `part` and `tried`)."""
282
+ net, nokey = {}, []
283
+ for r in rows:
284
+ for step in re.split(r";\s+(?=[a-z_]+[: ])", r.get("tried") or ""):
285
+ src = step.split(":", 1)[0].split(" ", 1)[0].strip()
286
+ m = NET_RE.search(step)
287
+ if m and src:
288
+ net.setdefault(src, [set(), m.group(0)])[0].add(r["part"])
289
+ if "no API key" in step:
290
+ nokey.append(r["part"])
291
+ out = []
292
+ for src, (parts, why) in sorted(net.items()):
293
+ if offline:
294
+ out.append((f"{src}: not in the download cache for {len(parts)} part(s) (--offline)",
295
+ "run once without --offline to fill the cache"))
296
+ else:
297
+ out.append((f"network: {src} could not be reached (or refused) for {len(parts)} part(s) ({why})",
298
+ "check your internet connection or proxy, or skip online lookups: --offline --sources standard"))
299
+ if nokey:
300
+ env = ((rules or {}).get("datasheet") or {}).get("api_key_env") or "ANTHROPIC_API_KEY"
301
+ out.append((f"{len(set(nokey))} part(s) need the datasheet reader, but {env} is not set",
302
+ f"add it on the setup page (`ecadlib setup`) or with `ecadlib keys set {env}`, or take datasheet out of "
303
+ "[sources] order in ecadlib.toml"))
304
+ return out
305
+
306
+
307
+ # ------------------------------------------------------------------ ecadlib demo
308
+ def build_demo(ws, tool="both", bom=None, log=print):
309
+ """Build the demo library in Workspace `ws`, offline, from the bundled BOM (or `bom`). Returns a summary dict."""
310
+ from .app import Workspace
311
+ t0 = time.time()
312
+ root = Path(ws.root)
313
+ if root.resolve() == Path(root.resolve().anchor):
314
+ raise ValueError(f"refusing to build the demo at the filesystem root ({root}); give a folder, e.g. -o ecadlib-demo")
315
+ rules_f = root / "ecadlib.toml"
316
+ if rules_f.exists() and not rules_f.read_text(encoding="utf-8").startswith(DEMO_HEADER):
317
+ raise ValueError(f"{root} has its own ecadlib.toml; the demo needs a folder of its own (e.g. -o ecadlib-demo)")
318
+ if not rules_f.exists(): # the demo's own rules: standard parts only, so a stray ecadlib.toml in a
319
+ rules_f.write_text( # parent folder cannot change the result, and nothing goes on the network
320
+ DEMO_HEADER + ": standard parts only, no network, no API keys.\n"
321
+ "[sources]\norder = [\"standard\"]\n\n[setup]\n"
322
+ f"tool = \"{tool}\"\nlibs = []\nworkspace = \".\"\n", encoding="utf-8")
323
+ src = Path(bom) if bom else DEMO_BOM
324
+ dest = root / "uploads" / "demo" / src.name
325
+ dest.parent.mkdir(parents=True, exist_ok=True)
326
+ if not dest.exists() or dest.read_bytes() != src.read_bytes():
327
+ shutil.copyfile(src, dest)
328
+ rel = ws.rel(dest)
329
+ log(f"[1/3] BOM: {rel} ({sum(1 for _ in open(dest, encoding='utf-8')) - 1} lines)")
330
+ log("[2/3] finding symbols and footprints (built-in standard parts; no network, no API key) ...")
331
+ res = ws.librarian_run(rel, sources=["standard"])
332
+ files = {"plan": res["reports"]}
333
+ kicad = None
334
+ if tool in ("kicad", "both"):
335
+ log("[3/3] writing KiCad files (.kicad_sym, .pretty, lib tables, database library) ...")
336
+ kicad = ws.kicad_dblib(rel, name=DEMO_NAME)
337
+ else:
338
+ log("[3/3] Altium files are written by step 2 (.SchLib / .PcbLib)")
339
+ lib_dir = ws._librarian_dir(dest)
340
+ par = ws.parity(rel, kicad=bool(kicad), name=DEMO_NAME) if tool in ("kicad", "both") else None # both libraries agree
341
+ if par:
342
+ log(f" KiCad / Altium parity: {par['counts']['match']} parts match, {par['counts']['mismatch']} differ ({par['report']})")
343
+ altium = sorted(ws.rel(p) for p in (lib_dir / "models").glob("*/*") if p.suffix in (".SchLib", ".PcbLib"))
344
+ return {"workspace": str(root), "bom": rel, "parts": res["parts"], "ready": res["ready"], "held": res["held"],
345
+ "held_parts": [r["part"] for r in res["review"] if r["status"] != "ready"],
346
+ "review": [r for r in res["reports"] if r.endswith("review.md")],
347
+ "plan": ws.rel(lib_dir / "plan.json"), "altium": altium,
348
+ "kicad": kicad and {"folder": kicad["folder"], "files": kicad["files"]},
349
+ "parity": par and {"ok": par["ok"], "counts": par["counts"], "report": par["report"], "mismatches": par["mismatches"]},
350
+ "seconds": round(time.time() - t0, 1)}
351
+
352
+
353
+ def demo_main(argv=None):
354
+ ap = argparse.ArgumentParser(
355
+ prog="ecadlib demo", formatter_class=argparse.RawDescriptionHelpFormatter,
356
+ description="build a small library from a bundled sample BOM, fully offline (no network, no API keys), "
357
+ "so you can see a real result in under a minute",
358
+ epilog="examples:\n ecadlib demo # writes ./ecadlib-demo\n"
359
+ " ecadlib demo -o /tmp/try --tool kicad\n ecadlib demo --bom my_bom.csv # your BOM, standard parts only")
360
+ ap.add_argument("-o", "--out", "--workspace", dest="out", default="ecadlib-demo",
361
+ help="workspace folder to build in (default %(default)s); re-running reuses it")
362
+ ap.add_argument("--tool", choices=TOOLS, default=None, help="which files to write (default: [setup] tool, else both)")
363
+ ap.add_argument("--bom", help="use this BOM instead of the bundled sample (still offline, standard parts only)")
364
+ ap.add_argument("--json", action="store_true", help="print the summary as JSON")
365
+ a = ap.parse_args(argv)
366
+ from .app import Workspace
367
+ tool = a.tool
368
+ if tool is None:
369
+ try:
370
+ tool = (RU.load().get("setup") or {}).get("tool") or "both"
371
+ except RU.RulesError:
372
+ tool = "both"
373
+ say = (lambda *x: None) if a.json else print
374
+ say(f"ecadlib demo: building a small library in {a.out}/ from "
375
+ f"{'the bundled sample BOM' if not a.bom else a.bom}, offline")
376
+ ws = Workspace(a.out, offline=True)
377
+ try:
378
+ res = build_demo(ws, tool, a.bom, log=say)
379
+ except ValueError as e: # a folder with someone's rules, or a BOM the reader cannot use
380
+ print(f"ecadlib demo: {e}" + bom_hint(e), file=sys.stderr)
381
+ return 2
382
+ if a.json:
383
+ print(json.dumps(res, indent=1))
384
+ return 0
385
+ from . import term as TM
386
+ t = TM.Term()
387
+ written = [(f"{a.out}/{res['plan']}", "the library plan"), (", ".join(f"{a.out}/{r}" for r in res["review"]), "review: what to check, part by part")]
388
+ if res["altium"]:
389
+ written.append((f"{a.out}/{Path(res['altium'][0]).parent.parent}/", f"{len(res['altium'])} Altium files (.SchLib, .PcbLib)"))
390
+ if res["kicad"]:
391
+ kd = Path(res["workspace"]) / res["kicad"]["folder"]
392
+ written.append((f"{a.out}/{res['kicad']['folder']}/", "KiCad: " + ", ".join(sorted(p.name + ("/" if p.is_dir() else "")
393
+ for p in kd.iterdir() if p.suffix in (".kicad_sym", ".pretty", ".kicad_dbl")))))
394
+ t.summary(f"{res['parts']} parts: {res['ready']} ready, {res['held']} held in {res['seconds']} s"
395
+ + (f" (held: {', '.join(res['held_parts'])}; that is expected, see the review)" if res["held"] else ""),
396
+ written=written,
397
+ next=[(f"ecadlib status --workspace {a.out}", "one-screen summary"),
398
+ (f"ecadlib ui --workspace {a.out}", "look at it in the browser (held parts, sign-off, exports)"),
399
+ ("ecadlib init", "set up for your own libraries and BOMs")])
400
+ return 0