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