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/easy_setup.py ADDED
@@ -0,0 +1,338 @@
1
+ """The setup page behind `ecadlib setup` and the desktop app's first run: no terminal, no ecadlib.toml editing.
2
+
3
+ ecadlib setup [--workspace W] [--port 8765] opens the web UI at its setup page
4
+
5
+ What the page does (each step also works on its own, later):
6
+ 1. AI model: pick a provider and a model, paste the API key. The key is stored for this user (ecadlib.userconfig:
7
+ encrypted for the Windows account on Windows) and checked with a free request (the provider's model list); the
8
+ workspace's ecadlib.toml gets [ai] provider / model, never the key. "No AI" leaves the datasheet reader out of
9
+ [sources]: KiCad-library and standard parts need no key.
10
+ 2. Tools: which of Altium Designer / KiCad is installed (ecadlib.detect), and which one you make libraries for.
11
+ 3. Folders: the workspace (default Documents/ecadlib) and your existing library folders, if any.
12
+ 4. Add to Altium: writes the "ecadlib" menu script project (ecadlib.altium_menu) into <workspace>/altium-menu, set to
13
+ run this ecadlib, and shows the one step left inside Altium (Install from file...). Add to KiCad: copies the
14
+ "ecadlib: add part" PCB editor plugin into KiCad's plugin folder for each KiCad found, with this ecadlib's command
15
+ next to it, so it works without PATH changes.
16
+ Saving writes <workspace>/ecadlib.toml through `ecadlib init`'s own code (rules_infer.propose: it also learns naming
17
+ conventions from the library folders given) and the answers to the user's settings.json.
18
+
19
+ The routes (ecadlib/ui/server.py) exist in local mode only: team mode refuses them, since keys are per person."""
20
+ import argparse, json, os, shutil, sys
21
+ from pathlib import Path
22
+
23
+ from . import __version__, detect, userconfig as UC
24
+
25
+ RESTART = None # set by the desktop app (ecadlib.launcher): fn(new workspace) that restarts the server there
26
+
27
+ # (id, label, ecadlib.toml provider, key variable, models (first = default), where to get a key, note)
28
+ PROVIDERS = [
29
+ ("anthropic", "Claude (Anthropic)", "anthropic", "ANTHROPIC_API_KEY",
30
+ [("claude-opus-5-5", "most accurate (default)"), ("claude-sonnet-5-5", "balanced"), ("claude-haiku-5-5", "cheapest")],
31
+ "https://console.anthropic.com/settings/keys", "Reads datasheet PDFs directly. The default and the best tested."),
32
+ ("openai", "OpenAI", "openai", "OPENAI_API_KEY",
33
+ [("gpt-5", "most accurate"), ("gpt-5-mini", "cheaper")],
34
+ "https://platform.openai.com/api-keys", ""),
35
+ ("gemini", "Google Gemini", "gemini", "GEMINI_API_KEY",
36
+ [("gemini-2.5-pro", "most accurate"), ("gemini-2.5-flash", "cheaper")],
37
+ "https://aistudio.google.com/apikey", "Gemini gets datasheet pages as pictures and text, not the PDF."),
38
+ ("openrouter", "OpenRouter (many models, one key)", "openrouter", "OPENROUTER_API_KEY",
39
+ [("anthropic/claude-sonnet-4.5", "type any OpenRouter model id")],
40
+ "https://openrouter.ai/settings/keys", ""),
41
+ ("ollama", "A model on this computer (Ollama)", "ollama", "",
42
+ [("qwen2.5vl:7b", "type the model name you pulled")],
43
+ "https://ollama.com/download", "Free and private; small local models get extra guide rails and are less accurate."),
44
+ ("none", "No AI for now", "", "", [], "",
45
+ "Parts from KiCad's library, EasyEDA/LCSC and ecadlib's standard packages still work. Add a model any time."),
46
+ ]
47
+ EXTRA_KEYS = [("NEXAR_TOKEN", "Altium 365 (Nexar) token", "send parts to an Altium 365 workspace",
48
+ "https://portal.nexar.com/applications")]
49
+ TOOLS = ("altium", "kicad", "both")
50
+
51
+
52
+ def _provider(pid):
53
+ p = next((p for p in PROVIDERS if p[0] == pid), None)
54
+ if p is None:
55
+ raise ValueError(f"unknown AI provider {pid!r}")
56
+ return p
57
+
58
+
59
+ def _ws_rules_raw(ws_root):
60
+ from . import rules as RU
61
+ f = Path(ws_root) / "ecadlib.toml"
62
+ try:
63
+ return RU.parse_text(f.read_text(encoding="utf-8"), f.name) if f.is_file() else {}
64
+ except (OSError, RU.RulesError):
65
+ return {}
66
+
67
+
68
+ def current_ai(raw):
69
+ """(provider id, model) the workspace's ecadlib.toml names; ("anthropic", default) when it names none."""
70
+ ai = raw.get("ai") or {}
71
+ prov = str(ai.get("provider") or "anthropic")
72
+ model = str(ai.get("model") or (raw.get("datasheet") or {}).get("model") or "")
73
+ order = (raw.get("sources") or {}).get("order")
74
+ if isinstance(order, list) and "datasheet" not in order:
75
+ return "none", ""
76
+ pid = next((p[0] for p in PROVIDERS if p[2] == prov), "anthropic" if prov == "anthropic" else "openrouter")
77
+ return pid, model or (_provider(pid)[4][0][0] if _provider(pid)[4] else "")
78
+
79
+
80
+ def _key_row(name, label, why, url, environ):
81
+ stored = name in UC.stored_names()
82
+ return {"name": name, "label": label, "why": why, "url": url, "stored": stored,
83
+ "set": bool(environ.get(name)), "from_env": bool(environ.get(name)) and not stored}
84
+
85
+
86
+ def state(ws_root, environ=None):
87
+ """Everything the setup page shows. Never contains a key value."""
88
+ environ = os.environ if environ is None else environ
89
+ s = UC.load_settings()
90
+ raw = _ws_rules_raw(ws_root)
91
+ st = raw.get("setup") or {}
92
+ pid, model = current_ai(raw)
93
+ found = detect.summary()
94
+ tool = st.get("tool") or s.get("tool") or ("both" if found["altium"] and found["kicad"] else "kicad"
95
+ if found["kicad"] and not found["altium"] else "altium" if found["altium"] else "both")
96
+ keys = [_key_row(p[3], f"{p[1]} API key", "the datasheet reader and visual check", p[5], environ)
97
+ for p in PROVIDERS if p[3]]
98
+ keys += [_key_row(n, label, why, url, environ) for n, label, why, url in EXTRA_KEYS]
99
+ return {"version": __version__, "workspace": str(Path(ws_root).resolve()),
100
+ "default_workspace": str(UC.default_workspace()), "done": bool(s.get("setup_done")),
101
+ "frozen": bool(getattr(sys, "frozen", False)), "platform": sys.platform,
102
+ "tool": tool, "libs": [str(x) for x in (st.get("libs") or s.get("libs") or [])],
103
+ "ai": {"provider": pid, "model": model},
104
+ "providers": [{"id": p[0], "label": p[1], "key": p[3], "models": [{"id": m, "note": n} for m, n in p[4]],
105
+ "key_url": p[5], "note": p[6]} for p in PROVIDERS],
106
+ "keys": keys, "detected": found, "installed": s.get("installed") or {},
107
+ "can_restart": RESTART is not None, "config_dir": str(UC.config_dir())}
108
+
109
+
110
+ # ------------------------------------------------------------------ saving
111
+ def save(ws_root, b):
112
+ """Apply the page's answers: {workspace, tool, libs, provider, model}. Returns {saved, workspace, restart, toml}.
113
+ A different workspace than the running one is created and gets the ecadlib.toml; restart says the server must
114
+ move there (the desktop app does it; `ecadlib ui` users restart it with --workspace)."""
115
+ from . import rules_infer as RI, rules as RU
116
+ tool = b.get("tool") or "both"
117
+ if tool not in TOOLS:
118
+ raise ValueError(f"tool must be one of {', '.join(TOOLS)}")
119
+ libs = b.get("libs") or []
120
+ if not isinstance(libs, list) or not all(isinstance(x, str) for x in libs) or len(libs) > 50:
121
+ raise ValueError("libs must be a list of folders")
122
+ libs = [str(Path(x.strip()).expanduser()) for x in libs if x.strip()]
123
+ missing = [x for x in libs if not Path(x).is_dir()]
124
+ if missing:
125
+ raise ValueError(f"library folder not found: {missing[0]}")
126
+ ws = b.get("workspace") or str(ws_root)
127
+ if not isinstance(ws, str) or len(ws) > 500:
128
+ raise ValueError("bad workspace folder")
129
+ ws = Path(ws.strip()).expanduser()
130
+ if not ws.is_absolute():
131
+ raise ValueError("give the workspace as a full path, e.g. " + str(UC.default_workspace()))
132
+ if ws.exists() and not ws.is_dir():
133
+ raise ValueError(f"{ws} is a file, not a folder")
134
+ p = _provider(b.get("provider") or "anthropic")
135
+ model = str(b.get("model") or (p[4][0][0] if p[4] else "")).strip()
136
+ from .llm import MODEL_RE
137
+ if p[0] != "none" and not MODEL_RE.fullmatch(model):
138
+ raise ValueError("pick or type a model name")
139
+ ws.mkdir(parents=True, exist_ok=True)
140
+ order = list(RU.DEFAULTS["sources"]["order"])
141
+ if p[0] == "none":
142
+ order = [x for x in order if x != "datasheet"]
143
+ extra = {"setup": {"tool": tool, "libs": libs, "workspace": "."}, "sources": {"order": order}}
144
+ if p[0] != "none":
145
+ extra["ai"] = {"provider": p[2], "model": model}
146
+ old_ai = _ws_rules_raw(ws).get("ai") or {}
147
+ extra["ai"].update({k: "" for k in ("base_url", "api_key_env") if old_ai.get(k)}) # a preset brings its own
148
+ out = ws / "ecadlib.toml"
149
+ prop = RI.propose([x for x in libs] or [], existing=out, extra=extra)
150
+ if prop["errors"]:
151
+ raise ValueError("; ".join(prop["errors"]))
152
+ res = RI.save(prop["toml"], out, overwrite=True)
153
+ UC.save_settings({"workspace": str(ws), "tool": tool, "libs": libs, "setup_done": True})
154
+ moved = ws.resolve() != Path(ws_root).resolve()
155
+ return {"saved": True, "workspace": str(ws.resolve()), "restart": moved, "can_restart": RESTART is not None,
156
+ "toml": str(out), "backup": res.get("backup"), "learned": len(prop["settings"])}
157
+
158
+
159
+ def restart(ws):
160
+ if RESTART is None:
161
+ raise ValueError(f"restart ecadlib with: ecadlib ui --workspace \"{ws}\"")
162
+ RESTART(str(ws))
163
+ return {"restarting": True, "workspace": str(ws)}
164
+
165
+
166
+ # ------------------------------------------------------------------ keys
167
+ def store_key(b):
168
+ name, value = b.get("name"), b.get("value")
169
+ allowed = {p[3] for p in PROVIDERS if p[3]} | {k[0] for k in EXTRA_KEYS}
170
+ if name not in allowed:
171
+ raise ValueError(f"{name!r} is not a key this page stores; use `ecadlib keys set NAME` for others")
172
+ if not isinstance(value, str):
173
+ raise ValueError("no key given")
174
+ UC.set_key(name, value)
175
+ os.environ[name] = value.strip() # this running server uses it at once
176
+ return {"stored": name}
177
+
178
+
179
+ def forget_key(b):
180
+ name = b.get("name")
181
+ UC.check_name(name if isinstance(name, str) else "")
182
+ old = UC.get_key(name)
183
+ gone = UC.remove_key(name)
184
+ if gone and old is not None and os.environ.get(name) == old:
185
+ os.environ.pop(name) # the copy store_key / apply_env put here: this server stops using it
186
+ return {"removed": gone, "still_in_env": bool(os.environ.get(name))}
187
+
188
+
189
+ def check_key(b, get=None):
190
+ """A free request that proves the key works: the provider's model list. -> {ok, message}. Sends the key only to
191
+ that provider's own address."""
192
+ p = _provider(b.get("provider") or "anthropic")
193
+ if p[0] == "none":
194
+ return {"ok": True, "message": "no AI chosen: nothing to check"}
195
+ from . import llm as LL
196
+ base = "https://api.anthropic.com/v1" if p[2] == "anthropic" else LL.PRESETS[p[2]][0]
197
+ key = os.environ.get(p[3]) if p[3] else None
198
+ if p[3] and not key:
199
+ return {"ok": False, "message": f"no key yet: paste your {p[1]} key above"}
200
+ if get is None:
201
+ import requests
202
+ get = requests.get
203
+ headers = ({"x-api-key": key, "anthropic-version": "2023-06-01"} if p[2] == "anthropic"
204
+ else {"Authorization": f"Bearer {key}"} if key else {})
205
+ try:
206
+ r = get(base.rstrip("/") + "/models", headers=headers, timeout=15)
207
+ except Exception as e:
208
+ where = "is Ollama running? Start it, then try again" if p[0] == "ollama" else "check the internet connection or proxy"
209
+ return {"ok": False, "message": f"could not reach {base} ({type(e).__name__}): {where}"}
210
+ code = getattr(r, "status_code", 0)
211
+ if code in (401, 403):
212
+ return {"ok": False, "message": f"{p[1]} refused the key ({code}): copy it again from {p[5]}"}
213
+ if code != 200:
214
+ return {"ok": False, "message": f"{p[1]} answered {code}; the key may still be fine, try again later"}
215
+ model = str(b.get("model") or "")
216
+ try:
217
+ ids = {m.get("id") or m.get("name") for m in (r.json().get("data") or r.json().get("models") or [])}
218
+ except Exception:
219
+ ids = set()
220
+ if model and ids and model not in ids and p[0] in ("ollama",):
221
+ return {"ok": False, "message": f"Ollama has no model {model}: run `ollama pull {model}` first"}
222
+ return {"ok": True, "message": f"{p[1]} accepted the key" if key else f"{p[1]} answered"}
223
+
224
+
225
+ # ------------------------------------------------------------------ add to Altium / KiCad
226
+ def _record_install(kind, value):
227
+ s = UC.load_settings()
228
+ inst = dict(s.get("installed") or {})
229
+ inst[kind] = value
230
+ UC.save_settings({"installed": inst})
231
+
232
+
233
+ def install_altium_menu(ws_root):
234
+ """Write the Altium menu script project into <workspace>/altium-menu, run by this ecadlib. -> {folder, project,
235
+ steps}. Altium keeps its list of installed script projects to itself, so the last step is one click inside Altium."""
236
+ from . import altium_menu as AM
237
+ ws = Path(ws_root).resolve()
238
+ out = ws / "altium-menu"
239
+ cmd = UC.self_command()
240
+ command = " ".join(f'"{c}"' if " " in c else c for c in cmd)
241
+ rep = AM.build(out, workspace=str(ws), library=str(ws / "altium-library"), command=command,
242
+ win_dir=str(out) if os.name == "nt" else None)
243
+ ini = out / AM.INI
244
+ _set_ini_command(ini, command) # an older build's .ini is kept: point it at this ecadlib
245
+ project = out / AM.PROJECT
246
+ _record_install("altium_menu", str(project))
247
+ bar = {}
248
+ if os.name == "nt": # the "ecadlib" menu straight into Altium's top menu bar (Altium closed)
249
+ try:
250
+ bar = AM.install_menu_bar(out)
251
+ except (OSError, ValueError) as e:
252
+ bar = {"error": str(e)}
253
+ res = {"folder": str(out), "project": str(project), "menu_file": str(out / AM.PREFS) if rep.get("menu_file") else None,
254
+ "problems": rep.get("problems") or [], "menu_bar": bar}
255
+ if bar.get("folders") and not bar.get("error"):
256
+ res["steps"] = ["Start Altium Designer and open a schematic or PCB",
257
+ "The ecadlib menu is in the menu bar, before Window: Add part from MPN, Check this project BOM, "
258
+ "Open review queue",
259
+ f"To take it out again: ecadlib altium-menu install-menu --out \"{out}\" --remove"]
260
+ else:
261
+ why = f" ({bar['error']})" if bar.get("error") else ""
262
+ res["steps"] = [f"Close Altium Designer{why}",
263
+ f"Run: ecadlib altium-menu install-menu --out \"{out}\" (or press Add to Altium here again)",
264
+ "Start Altium: the ecadlib menu is in the menu bar, before Window. Without closing Altium: "
265
+ f"Preferences > Load... with {out / AM.PREFS} (see ALTIUM_MENU_README.txt)"]
266
+ return res
267
+
268
+
269
+ def _set_ini_command(ini, command):
270
+ try:
271
+ lines = ini.read_text(encoding="utf-8").splitlines()
272
+ except OSError:
273
+ return
274
+ out, done = [], False
275
+ for ln in lines:
276
+ if ln.strip().lower().startswith("command") and "=" in ln and not done:
277
+ out.append(f"command={command}")
278
+ done = True
279
+ else:
280
+ out.append(ln)
281
+ if done:
282
+ ini.write_text("\n".join(out) + "\n", encoding="utf-8")
283
+
284
+
285
+ def install_kicad_plugin(series=None):
286
+ """Copy the action plugin into KiCad's per-user plugin folder (every KiCad found, or one series). -> {installed:
287
+ [folder], steps}."""
288
+ kicads = [k for k in detect.kicad() if k["series"]]
289
+ if series:
290
+ kicads = [k for k in kicads if k["series"] == series] or [{"series": series}]
291
+ if not kicads:
292
+ raise ValueError("no KiCad found on this computer; install KiCad 7 or newer, then press this again")
293
+ from .kicad_menu import icon_png
294
+ src = Path(__file__).with_name("assets") / "kicad_action_plugin.py"
295
+ done = []
296
+ for k in kicads:
297
+ d = detect.kicad_plugin_dir(k["series"]) / "ecadlib_add_part"
298
+ d.mkdir(parents=True, exist_ok=True)
299
+ shutil.copyfile(src, d / "__init__.py")
300
+ (d / "ecadlib.png").write_bytes(icon_png(24))
301
+ (d / "ecadlib_cmd.json").write_text(json.dumps({"command": UC.self_command(), "ecadlib": __version__}, indent=2) + "\n",
302
+ encoding="utf-8")
303
+ done.append(str(d))
304
+ _record_install("kicad_plugin", done)
305
+ return {"installed": done,
306
+ "steps": ["Restart KiCad (or in the PCB editor: Tools > External Plugins > Refresh Plugins)",
307
+ "Open a saved board, then press the ecadlib button in the top toolbar (also under Tools > "
308
+ "External Plugins)"]}
309
+
310
+
311
+ # ------------------------------------------------------------------ web routes (local mode only)
312
+ # Not in ui/server.py's ROUTES on purpose: team mode answers only ROUTES, so these are 404 there (keys and installs
313
+ # are per person, on their own computer). Local mode still checks Host and Origin before any of them runs.
314
+ GET_ROUTES = {"/api/setup": lambda ws, b: state(ws)}
315
+ POST_ROUTES = {"/api/setup": save, "/api/setup/key": lambda ws, b: store_key(b),
316
+ "/api/setup/key/forget": lambda ws, b: forget_key(b), "/api/setup/key/check": lambda ws, b: check_key(b),
317
+ "/api/setup/altium": lambda ws, b: install_altium_menu(ws),
318
+ "/api/setup/kicad": lambda ws, b: install_kicad_plugin(b.get("series")),
319
+ "/api/setup/restart": lambda ws, b: restart(b.get("workspace") or ws)}
320
+ WRITES = {"/api/setup", "/api/setup/altium"} # these write into the workspace: under the server's write lock
321
+
322
+
323
+ # ------------------------------------------------------------------ ecadlib setup
324
+ def main(argv=None):
325
+ ap = argparse.ArgumentParser(prog="ecadlib setup", description="open the setup page: AI model and key, Altium / "
326
+ "KiCad, folders, add ecadlib to Altium and KiCad (no ecadlib.toml editing)")
327
+ ap.add_argument("--workspace", help="workspace folder (default: the one chosen before, else Documents/ecadlib)")
328
+ ap.add_argument("--port", type=int, default=8765)
329
+ ap.add_argument("--no-browser", action="store_true")
330
+ a = ap.parse_args(argv)
331
+ from .ui import server
332
+ ws = a.workspace or UC.load_settings().get("workspace") or str(UC.default_workspace())
333
+ Path(ws).mkdir(parents=True, exist_ok=True)
334
+ return server.main(["--workspace", ws, "--port", str(a.port), "--setup"] + (["--no-browser"] if a.no_browser else []))
335
+
336
+
337
+ if __name__ == "__main__":
338
+ sys.exit(main())