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/detect.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Find Altium Designer and KiCad on this machine, for the setup page (and the doctor's kicad-cli check).
|
|
2
|
+
|
|
3
|
+
Nothing is run except `kicad-cli version` (to learn KiCad's version when the install folder does not say it), and
|
|
4
|
+
nothing is written. Every function takes the folders it looks in, so tests can point them at a fake tree.
|
|
5
|
+
|
|
6
|
+
Altium Designer (Windows only): the registry's Builds keys (HKLM\\SOFTWARE\\Altium\\Builds\\*: ProgramsInstallPath,
|
|
7
|
+
Version), then the default install folders <Program Files>\\Altium\\AD*\\X2.EXE.
|
|
8
|
+
KiCad: <Program Files>\\KiCad\\<version>\\bin (Windows), /Applications/KiCad (macOS), kicad / kicad-cli on PATH (all).
|
|
9
|
+
KiCad's per-user folders (where the setup page copies the action plugin), from KiCad's own paths code (paths.cpp,
|
|
10
|
+
GetDefaultUserDocumentsPath / GetUserScriptingPath): Windows and macOS Documents/KiCad/<major.minor>, Linux
|
|
11
|
+
$XDG_DATA_HOME/kicad/<major.minor> (~/.local/share/kicad/...); the plugins go in scripting/plugins below it."""
|
|
12
|
+
import os, re, shutil, subprocess, sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _program_files(environ=None):
|
|
17
|
+
environ = os.environ if environ is None else environ
|
|
18
|
+
out = []
|
|
19
|
+
for v in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"):
|
|
20
|
+
p = environ.get(v)
|
|
21
|
+
if p and Path(p) not in out:
|
|
22
|
+
out.append(Path(p))
|
|
23
|
+
return out or [Path("C:/Program Files")]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _version_key(v):
|
|
27
|
+
return tuple(int(x) for x in re.findall(r"\d+", str(v))[:3]) or (0,)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------ Altium Designer
|
|
31
|
+
def _altium_registry():
|
|
32
|
+
try:
|
|
33
|
+
import winreg
|
|
34
|
+
except ImportError:
|
|
35
|
+
return []
|
|
36
|
+
out = []
|
|
37
|
+
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
|
|
38
|
+
try:
|
|
39
|
+
root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Altium\Builds", 0, winreg.KEY_READ | view)
|
|
40
|
+
except OSError:
|
|
41
|
+
continue
|
|
42
|
+
with root:
|
|
43
|
+
i = 0
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
sub = winreg.EnumKey(root, i)
|
|
47
|
+
except OSError:
|
|
48
|
+
break
|
|
49
|
+
i += 1
|
|
50
|
+
try:
|
|
51
|
+
with winreg.OpenKey(root, sub) as k:
|
|
52
|
+
vals = {}
|
|
53
|
+
for name in ("ProgramsInstallPath", "Version", "ProductName", "FullBuild"):
|
|
54
|
+
try:
|
|
55
|
+
vals[name] = str(winreg.QueryValueEx(k, name)[0])
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
except OSError:
|
|
59
|
+
continue
|
|
60
|
+
if vals.get("ProgramsInstallPath"):
|
|
61
|
+
out.append(vals)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def altium(program_files=None, registry=None):
|
|
66
|
+
"""[{name, version, folder, exe}] newest first; [] on a machine without Altium (or not Windows)."""
|
|
67
|
+
if program_files is None and os.name != "nt":
|
|
68
|
+
return []
|
|
69
|
+
found = {}
|
|
70
|
+
for r in (_altium_registry() if registry is None else registry):
|
|
71
|
+
folder = Path(r["ProgramsInstallPath"])
|
|
72
|
+
exe = folder / "X2.EXE"
|
|
73
|
+
if exe.is_file():
|
|
74
|
+
found[str(folder).lower()] = {"name": r.get("ProductName") or "Altium Designer",
|
|
75
|
+
"version": r.get("Version") or r.get("FullBuild") or "", "folder": str(folder),
|
|
76
|
+
"exe": str(exe)}
|
|
77
|
+
for pf in (_program_files() if program_files is None else program_files):
|
|
78
|
+
for exe in sorted(Path(pf).glob("Altium/*/X2.EXE")) + sorted(Path(pf).glob("Altium/*/X2.exe")):
|
|
79
|
+
key = str(exe.parent).lower()
|
|
80
|
+
if key not in found:
|
|
81
|
+
m = re.match(r"AD(\d+)", exe.parent.name, re.I)
|
|
82
|
+
found[key] = {"name": "Altium Designer" + (f" {m.group(1)}" if m else ""),
|
|
83
|
+
"version": m.group(1) if m else "", "folder": str(exe.parent), "exe": str(exe)}
|
|
84
|
+
return sorted(found.values(), key=lambda x: _version_key(x["version"]), reverse=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def altium_library_folders(public=None):
|
|
88
|
+
"""Altium's own example library folders (Users\\Public\\Documents\\Altium\\AD*\\Library), as suggestions."""
|
|
89
|
+
if public is None:
|
|
90
|
+
if os.name != "nt":
|
|
91
|
+
return []
|
|
92
|
+
public = Path(os.environ.get("PUBLIC") or "C:/Users/Public") / "Documents"
|
|
93
|
+
return [str(p) for p in sorted(Path(public).glob("Altium/*/Library"), reverse=True) if p.is_dir()]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------ KiCad
|
|
97
|
+
def _cli_version(cli, run=subprocess.run):
|
|
98
|
+
try:
|
|
99
|
+
r = run([str(cli), "version"], capture_output=True, text=True, timeout=20)
|
|
100
|
+
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", r.stdout or "")
|
|
101
|
+
return m.group(0) if m else ""
|
|
102
|
+
except (OSError, subprocess.SubprocessError):
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def kicad(program_files=None, applications=None, which=shutil.which, run=subprocess.run):
|
|
107
|
+
"""[{name, version, series, folder, cli}] newest first. series is the major.minor of KiCad's per-user folders."""
|
|
108
|
+
found = {}
|
|
109
|
+
|
|
110
|
+
def add(folder, cli, version):
|
|
111
|
+
series = ".".join(re.findall(r"\d+", version)[:2]) if version else ""
|
|
112
|
+
if series and "." not in series:
|
|
113
|
+
series += ".0"
|
|
114
|
+
found.setdefault(str(Path(cli).resolve()) if cli else str(folder), {
|
|
115
|
+
"name": "KiCad" + (f" {series}" if series else ""), "version": version, "series": series,
|
|
116
|
+
"folder": str(folder), "cli": str(cli) if cli else ""})
|
|
117
|
+
|
|
118
|
+
if program_files is not None or os.name == "nt":
|
|
119
|
+
for pf in (_program_files() if program_files is None else program_files):
|
|
120
|
+
for d in sorted(Path(pf).glob("KiCad/*")):
|
|
121
|
+
b = d / "bin"
|
|
122
|
+
if (b / "kicad.exe").is_file() or (b / "kicad-cli.exe").is_file():
|
|
123
|
+
cli = b / "kicad-cli.exe"
|
|
124
|
+
add(d, cli if cli.is_file() else None, d.name if re.match(r"\d", d.name) else "")
|
|
125
|
+
apps = applications if applications is not None else ([Path("/Applications")] if sys.platform == "darwin" else [])
|
|
126
|
+
for a in apps:
|
|
127
|
+
cli = Path(a) / "KiCad" / "KiCad.app" / "Contents" / "MacOS" / "kicad-cli"
|
|
128
|
+
if cli.is_file():
|
|
129
|
+
add(cli.parent.parent.parent, cli, _cli_version(cli, run))
|
|
130
|
+
for name in ("kicad-cli",):
|
|
131
|
+
p = which(name)
|
|
132
|
+
if p and not any(Path(x["cli"] or "/").resolve() == Path(p).resolve() for x in found.values()):
|
|
133
|
+
add(Path(p).parent, Path(p), _cli_version(p, run))
|
|
134
|
+
return sorted(found.values(), key=lambda x: _version_key(x["version"] or x["series"]), reverse=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def kicad_cli(program_files=None, applications=None):
|
|
138
|
+
"""The newest kicad-cli found (also when it is not on PATH), or None."""
|
|
139
|
+
for k in kicad(program_files, applications):
|
|
140
|
+
if k["cli"]:
|
|
141
|
+
return k["cli"]
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def kicad_user_dir(series, home=None, environ=None, platform=None):
|
|
146
|
+
"""KiCad's per-user folder for a version series ("8.0"): where scripting/plugins and 3rdparty live."""
|
|
147
|
+
environ = os.environ if environ is None else environ
|
|
148
|
+
platform = platform or ("windows" if os.name == "nt" else "macos" if sys.platform == "darwin" else "linux")
|
|
149
|
+
home = Path(home) if home else Path.home()
|
|
150
|
+
if platform == "linux":
|
|
151
|
+
base = Path(environ.get("XDG_DATA_HOME") or home / ".local" / "share") / "kicad"
|
|
152
|
+
else:
|
|
153
|
+
docs = home / "Documents"
|
|
154
|
+
if platform == "windows" and home == Path.home():
|
|
155
|
+
from .userconfig import documents_dir
|
|
156
|
+
docs = documents_dir()
|
|
157
|
+
base = docs / "KiCad"
|
|
158
|
+
return base / series
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def kicad_plugin_dir(series, **kw):
|
|
162
|
+
return kicad_user_dir(series, **kw) / "scripting" / "plugins"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def summary():
|
|
166
|
+
"""Everything the setup page shows: {altium: [...], kicad: [...], altium_libraries: [...]}."""
|
|
167
|
+
ks = kicad()
|
|
168
|
+
for k in ks:
|
|
169
|
+
k["plugin_dir"] = str(kicad_plugin_dir(k["series"])) if k["series"] else ""
|
|
170
|
+
return {"altium": altium(), "kicad": ks, "altium_libraries": altium_library_folders()}
|
ecadlib/doctor.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""`ecadlib doctor`: one screen that says whether this machine is ready, each line OK / WARN / FAIL with a fix.
|
|
2
|
+
|
|
3
|
+
ecadlib doctor [--workspace W] [--lib DIR ...] [--rules FILE] [--altium-menu ecadlib_menu.ini] [--json]
|
|
4
|
+
|
|
5
|
+
Checks: the Python version; the packages ecadlib needs (FAIL when missing) and the optional ones (pillow, pypdf) and
|
|
6
|
+
tools (pdftoppm, kicad-cli, git) (WARN only when a feature you turned on needs them: git and pdftoppm for the library
|
|
7
|
+
agent, kicad-cli unless you only make Altium libraries; kicad-cli is also found in KiCad's install folder when it is
|
|
8
|
+
not on PATH); which API keys are set (names only, never the values; Altium 365 and GitHub warn only when configured,
|
|
9
|
+
the optional part-lookup services are one line); the rules file
|
|
10
|
+
(ecadlib.toml: which one is used and whether it is valid - errors name file:line where the line can be found); the
|
|
11
|
+
workspace (exists, writable); the library folders (--lib / $ECADLIB_LIB, [agent] libs and library_repo) and the watched
|
|
12
|
+
sources in [agent.watch]; the Altium menu's command= (--altium-menu, else an ecadlib_menu.ini in the current folder or
|
|
13
|
+
out/altium_menu): set, and the program it names runs `ecadlib --version`; with --altium-menu the workspace checked is
|
|
14
|
+
the ini's workspace= unless --workspace is given. Exit code 1 when any line is FAIL, else 0. --json prints {"ok", "checks": [{"check",
|
|
15
|
+
"level", "detail", "hint"}]}. Nothing is written except a temporary probe file in the workspace, removed at once."""
|
|
16
|
+
import argparse, importlib.util, json, os, re, shutil, subprocess, sys, tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
LEVELS = ("OK", "WARN", "FAIL")
|
|
20
|
+
CORE = (("requests", "requests"), ("pandas", "pandas"), ("openpyxl", "openpyxl"), ("jsonschema", "jsonschema"),
|
|
21
|
+
("olefile", "olefile"))
|
|
22
|
+
OPTIONAL = (("PIL", "pillow", "visual-check pictures"), ("pypdf", "pypdf", "datasheet page trimming"))
|
|
23
|
+
TOOLS = (("pdftoppm", "datasheet pages for visual-check", "install poppler-utils"),
|
|
24
|
+
("kicad-cli", "KiCad checks and SVG previews", "install KiCad 7 or newer"),
|
|
25
|
+
("git", "the library agent's batch PRs", "install git"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _c(check, level, detail, hint=""):
|
|
29
|
+
return {"check": check, "level": level, "detail": detail, "hint": hint}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _toml_ok():
|
|
33
|
+
try:
|
|
34
|
+
from .rules import toml_module
|
|
35
|
+
return toml_module() is not None
|
|
36
|
+
except ImportError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_python(v=None):
|
|
41
|
+
v = tuple(v or sys.version_info[:3])
|
|
42
|
+
s = ".".join(map(str, v))
|
|
43
|
+
if v < (3, 10):
|
|
44
|
+
return _c("python", "FAIL", f"Python {s}", "ecadlib needs Python 3.10 or newer")
|
|
45
|
+
if v < (3, 11) and not (v == tuple(sys.version_info[:3]) and _toml_ok()): # 3.10 reads TOML with tomli
|
|
46
|
+
return _c("python", "WARN", f"Python {s}", "3.10 cannot read TOML without tomli: pip install tomli, "
|
|
47
|
+
"use ecadlib_rules.json, or Python 3.11+")
|
|
48
|
+
return _c("python", "OK", f"Python {s}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _needs(tool, rules):
|
|
52
|
+
"""Does a feature that is turned on need this tool? (so a fresh install is not a page of warnings)"""
|
|
53
|
+
r = rules if isinstance(rules, dict) else {}
|
|
54
|
+
if tool in ("git", "pdftoppm"):
|
|
55
|
+
return bool((r.get("agent") or {}).get("enabled"))
|
|
56
|
+
if tool == "kicad-cli":
|
|
57
|
+
return ((r.get("setup") or {}).get("tool") or "both") != "altium"
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _find_tool(tool, which):
|
|
62
|
+
p = which(tool)
|
|
63
|
+
if p or tool != "kicad-cli" or which is not shutil.which:
|
|
64
|
+
return p
|
|
65
|
+
try:
|
|
66
|
+
from . import detect
|
|
67
|
+
return detect.kicad_cli()
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_packages(find_spec=importlib.util.find_spec, which=shutil.which, rules=None):
|
|
73
|
+
out = []
|
|
74
|
+
missing = [pip for mod, pip in CORE if find_spec(mod) is None]
|
|
75
|
+
out.append(_c("packages", "FAIL", f"missing: {', '.join(missing)}", f"pip install {' '.join(missing)}") if missing
|
|
76
|
+
else _c("packages", "OK", ", ".join(pip for _, pip in CORE)))
|
|
77
|
+
for mod, pip, why in OPTIONAL:
|
|
78
|
+
out.append(_c(f"optional {pip}", "OK", "installed") if find_spec(mod) is not None
|
|
79
|
+
else _c(f"optional {pip}", "WARN", f"not installed ({why})", f"pip install {pip}"))
|
|
80
|
+
for tool, why, fix in TOOLS:
|
|
81
|
+
p = _find_tool(tool, which)
|
|
82
|
+
if p:
|
|
83
|
+
out.append(_c(f"tool {tool}", "OK", p))
|
|
84
|
+
elif _needs(tool, rules):
|
|
85
|
+
out.append(_c(f"tool {tool}", "WARN", f"not found ({why})", fix))
|
|
86
|
+
else:
|
|
87
|
+
out.append(_c(f"tool {tool}", "OK", f"not installed; only needed for {why}"))
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def key_services(rules=None):
|
|
92
|
+
"""[(service, [env var names], what it unlocks)] - the names come from the rules where the rules name them."""
|
|
93
|
+
ds = ((rules or {}).get("datasheet") or {}).get("api_key_env") or "ANTHROPIC_API_KEY"
|
|
94
|
+
gh = ((rules or {}).get("agent") or {}).get("github_token_env") or "GITHUB_TOKEN"
|
|
95
|
+
out = [("anthropic", [ds], "datasheet reader and visual check")] if _ai_provider(rules) == "anthropic" else []
|
|
96
|
+
out += [("altium365", ["NEXAR_TOKEN"], "Altium 365 push / pull")]
|
|
97
|
+
try:
|
|
98
|
+
from . import providers as P
|
|
99
|
+
out += [(c.name, list(c.env), f"{c.name} part lookups") for c in P.ALL if c.env]
|
|
100
|
+
except Exception: # a missing package is reported by check_packages
|
|
101
|
+
pass
|
|
102
|
+
out.append(("github", [gh], "library agent PRs, upkeep issues"))
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ai_provider(rules):
|
|
107
|
+
try:
|
|
108
|
+
from . import llm as LL
|
|
109
|
+
return LL.endpoint(rules if isinstance(rules, dict) else None, "datasheet")["provider"]
|
|
110
|
+
except Exception:
|
|
111
|
+
return "anthropic"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_ai(rules=None, environ=None):
|
|
115
|
+
"""The model every AI feature uses ([ai], ecadlib.llm): provider, model, tier and rails, and whether its key
|
|
116
|
+
variable is set (names only). Nothing is sent: the profile comes from the built-in table and ecadlib.toml."""
|
|
117
|
+
from . import llm as LL
|
|
118
|
+
from .llm import profiles as PR, rails as R
|
|
119
|
+
environ = os.environ if environ is None else environ
|
|
120
|
+
try:
|
|
121
|
+
ep = LL.endpoint(rules if isinstance(rules, dict) else None, "datasheet")
|
|
122
|
+
except LL.AIConfigError as e:
|
|
123
|
+
return [_c("ai model", "FAIL", str(e), "fix [ai] in ecadlib.toml (README: Bring your own model)")]
|
|
124
|
+
prof = PR.profile(ep["model"], ep["provider"], rules)
|
|
125
|
+
plan = R.plan(prof, ep["assist"], ep["feedback_retry"])
|
|
126
|
+
guide = [k for k in plan["rails"] if R.RAILS[k][0] != "support"]
|
|
127
|
+
where = "Anthropic API" if ep["provider"] == "anthropic" else f"{ep['base_url']}" + (" (local)" if ep["local"] else "")
|
|
128
|
+
out = [_c("ai model", "OK", f"{ep['model']} via {ep['name']} ({where}); {plan['tier']} tier, rails: "
|
|
129
|
+
f"{', '.join(guide) or 'support only'}")]
|
|
130
|
+
if ep["provider"] == "anthropic": # its key is the `key anthropic` line below
|
|
131
|
+
return out
|
|
132
|
+
key = LL.key_for(ep, environ=environ)
|
|
133
|
+
if key == LL.NO_KEY:
|
|
134
|
+
out.append(_c("ai key", "OK", "none needed (a server on this machine or your network)"))
|
|
135
|
+
elif key:
|
|
136
|
+
out.append(_c("ai key", "OK", f"{ep['key_env'] or 'ANTHROPIC_API_KEY'} set"))
|
|
137
|
+
else:
|
|
138
|
+
out.append(_c("ai key", "WARN", f"{ep['key_env']} not set (datasheet reader and visual-check skipped)",
|
|
139
|
+
key_hint(ep["key_env"])))
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def key_hint(name):
|
|
144
|
+
"""How to set a key on any OS (the setup page, or `ecadlib keys set`); ecadlib.toml only ever holds the name."""
|
|
145
|
+
from .userconfig import key_hint as h
|
|
146
|
+
return h(name) + " (ecadlib.toml keeps only the name, never the key)"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _configured(name, rules):
|
|
150
|
+
"""Is the feature behind this key turned on? Unset keys of features nobody uses are not warnings."""
|
|
151
|
+
r = rules if isinstance(rules, dict) else {}
|
|
152
|
+
if name == "altium365":
|
|
153
|
+
return bool((r.get("altium365") or {}).get("workspace"))
|
|
154
|
+
if name == "github":
|
|
155
|
+
ag = r.get("agent") or {}
|
|
156
|
+
return bool(ag.get("enabled") or ag.get("github_repo"))
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def check_keys(rules=None, environ=None):
|
|
161
|
+
environ = os.environ if environ is None else environ
|
|
162
|
+
out, lookups = [], []
|
|
163
|
+
for name, env, why in key_services(rules):
|
|
164
|
+
ok = all(environ.get(e) for e in env)
|
|
165
|
+
if name == "altium365" and not ok:
|
|
166
|
+
ok = bool(environ.get("NEXAR_CLIENT_ID") and environ.get("NEXAR_CLIENT_SECRET"))
|
|
167
|
+
env = env if not ok else ["NEXAR_CLIENT_ID", "NEXAR_CLIENT_SECRET"]
|
|
168
|
+
if why.endswith("part lookups"): # optional extra sources: one line for all of them
|
|
169
|
+
lookups.append((name, env, ok))
|
|
170
|
+
continue
|
|
171
|
+
if ok:
|
|
172
|
+
out.append(_c(f"key {name}", "OK", f"{' + '.join(env)} set"))
|
|
173
|
+
elif _configured(name, rules):
|
|
174
|
+
out.append(_c(f"key {name}", "WARN", f"{' + '.join(env)} not set ({why} skipped)", key_hint(env[0])
|
|
175
|
+
+ ("; NEXAR_CLIENT_ID + NEXAR_CLIENT_SECRET also work" if name == "altium365" else "")))
|
|
176
|
+
else:
|
|
177
|
+
out.append(_c(f"key {name}", "OK", f"{' + '.join(env)} not set; only needed for {why}"))
|
|
178
|
+
if lookups:
|
|
179
|
+
have = [n for n, _, ok in lookups if ok]
|
|
180
|
+
rest = [f"{n} ({' + '.join(e)})" for n, e, ok in lookups if not ok]
|
|
181
|
+
out.append(_c("key part lookups", "OK", (f"set: {', '.join(have)}" if have else "none set")
|
|
182
|
+
+ (f"; optional, not set: {', '.join(rest)}" if rest else "")
|
|
183
|
+
+ ("" if have else " (KiCad, EasyEDA/LCSC and standard parts need no key)")))
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def locate(err, path):
|
|
188
|
+
"""Line number in `path` that a RulesError message points at (TOML syntax errors carry it; validation errors name
|
|
189
|
+
a key such as footprint.density or [section]), or None."""
|
|
190
|
+
m = re.search(r"at line (\d+)", err)
|
|
191
|
+
if m:
|
|
192
|
+
return int(m.group(1))
|
|
193
|
+
try:
|
|
194
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
195
|
+
except OSError:
|
|
196
|
+
return None
|
|
197
|
+
m = re.search(re.escape(str(path)) + r": (\S+)", err)
|
|
198
|
+
if not m:
|
|
199
|
+
return None
|
|
200
|
+
key = m.group(1)
|
|
201
|
+
if key.startswith("[") and key.endswith("]"):
|
|
202
|
+
want = key.strip("[]")
|
|
203
|
+
return next((n for n, l in enumerate(lines, 1) if re.match(r"\s*\[+\s*" + re.escape(want) + r"\s*\]", l)), None)
|
|
204
|
+
parts = [p for p in re.split(r"[.\[\]]", key) if p and not p.isdigit()]
|
|
205
|
+
if len(parts) < 2:
|
|
206
|
+
return None
|
|
207
|
+
leaf, header = parts[-1], ""
|
|
208
|
+
kre = re.compile(r'\s*["\']?' + re.escape(leaf) + r'["\']?\s*=')
|
|
209
|
+
fallback = None
|
|
210
|
+
for n, l in enumerate(lines, 1):
|
|
211
|
+
h = re.match(r"\s*\[+\s*([^\]]+?)\s*\]", l)
|
|
212
|
+
if h:
|
|
213
|
+
header = h.group(1).replace('"', "")
|
|
214
|
+
continue
|
|
215
|
+
if kre.match(l):
|
|
216
|
+
if header == ".".join(parts[:-1]):
|
|
217
|
+
return n
|
|
218
|
+
if header.split(".")[0] == parts[0] and fallback is None:
|
|
219
|
+
fallback = n
|
|
220
|
+
if fallback:
|
|
221
|
+
return fallback
|
|
222
|
+
want = ".".join(parts[:-1]) # the key is in a sub-table header: [naming.by_category]
|
|
223
|
+
return next((n for n, l in enumerate(lines, 1) if re.match(r"\s*\[+\s*" + re.escape(want) + r"\s*\]", l)), None)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def check_rules(path=None, workspace=None):
|
|
227
|
+
"""(check, rules dict or None)."""
|
|
228
|
+
from . import rules as RU
|
|
229
|
+
try:
|
|
230
|
+
f = Path(path) if path else None
|
|
231
|
+
if f is None and workspace is not None:
|
|
232
|
+
f = next((Path(workspace) / n for n in RU.FILES if (Path(workspace) / n).is_file()), None)
|
|
233
|
+
if f is None:
|
|
234
|
+
f = RU.find(workspace if workspace is not None else ".")
|
|
235
|
+
except RU.RulesError as e: # ECADLIB_CONFIG names a missing file
|
|
236
|
+
return _c("rules", "FAIL", str(e), "fix or unset ECADLIB_CONFIG"), None
|
|
237
|
+
if f is None:
|
|
238
|
+
return _c("rules", "OK", "no ecadlib.toml found: ecadlib's defaults",
|
|
239
|
+
"`ecadlib init` writes one from your existing libraries"), RU.load({})
|
|
240
|
+
if not f.is_file():
|
|
241
|
+
return _c("rules", "FAIL", f"{f}: not found", "check the --rules path"), None
|
|
242
|
+
try:
|
|
243
|
+
r = RU.load(f)
|
|
244
|
+
except RU.RulesError as e:
|
|
245
|
+
n = locate(str(e), f)
|
|
246
|
+
where = f"{f}:{n}" if n else str(f)
|
|
247
|
+
msg = str(e)
|
|
248
|
+
msg = msg[len(str(f)) + 2:] if msg.startswith(str(f) + ": ") else msg
|
|
249
|
+
return _c("rules", "FAIL", f"{where}: {msg}", "fix that line; `ecadlib rules show` lists every key and its default"), None
|
|
250
|
+
src = " (from ECADLIB_CONFIG)" if os.environ.get("ECADLIB_CONFIG") and not path else ""
|
|
251
|
+
return _c("rules", "OK", f"{f}{src}: valid"), r
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def check_workspace(ws):
|
|
255
|
+
p = Path(ws).expanduser()
|
|
256
|
+
if not p.exists():
|
|
257
|
+
return _c("workspace", "OK", f"{p.resolve()}: does not exist yet; created on first use")
|
|
258
|
+
if not p.is_dir():
|
|
259
|
+
return _c("workspace", "FAIL", f"{p.resolve()}: not a folder", "point --workspace (or ECADLIB_WORKSPACE) at a folder")
|
|
260
|
+
try:
|
|
261
|
+
with tempfile.NamedTemporaryFile(dir=p, prefix=".ecadlib-doctor-"):
|
|
262
|
+
pass
|
|
263
|
+
except OSError as e:
|
|
264
|
+
return _c("workspace", "FAIL", f"{p.resolve()}: not writable ({e.strerror or e})", "fix the folder's permissions")
|
|
265
|
+
return _c("workspace", "OK", f"{p.resolve()}: writable")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def check_formats(ws):
|
|
269
|
+
"""Files written by another ecadlib version (ecadlib/migrate.py): older -> `ecadlib migrate`; newer -> upgrade."""
|
|
270
|
+
from . import migrate as MG
|
|
271
|
+
try:
|
|
272
|
+
rows = MG.scan(ws, max_depth=4)
|
|
273
|
+
except OSError as e:
|
|
274
|
+
return _c("file formats", "WARN", f"could not scan {ws} ({e})")
|
|
275
|
+
new = [r for r in rows if r["state"] in ("new", "error")]
|
|
276
|
+
old = [r for r in rows if r["state"] == "old"]
|
|
277
|
+
if new:
|
|
278
|
+
r = new[0]
|
|
279
|
+
return _c("file formats", "FAIL", r.get("error") or f"{r['path']}: made by ecadlib {r['made_by'] or '?'} "
|
|
280
|
+
f"({r['kind']} schema {r['version']})", "upgrade ecadlib (pip install -U ecadlib)")
|
|
281
|
+
if old:
|
|
282
|
+
return _c("file formats", "WARN", f"{len(old)} file(s) from an older ecadlib ({', '.join(sorted({r['kind'] for r in old}))})",
|
|
283
|
+
"they are migrated when used; `ecadlib migrate` does it now (backups in .ecadlib_backup/)")
|
|
284
|
+
return _c("file formats", "OK", f"{len(rows)} versioned file(s), all current")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _lib_summary(p):
|
|
288
|
+
try:
|
|
289
|
+
sym = sum(1 for _ in p.glob("*.kicad_sym")) + sum(1 for _ in p.glob("*/*.kicad_sym"))
|
|
290
|
+
fp = sum(1 for _ in p.glob("*.pretty")) + sum(1 for _ in p.glob("*/*.pretty"))
|
|
291
|
+
al = sum(1 for _ in p.glob("*.SchLib")) + sum(1 for _ in p.glob("*.PcbLib")) + sum(1 for _ in p.glob("*.DbLib"))
|
|
292
|
+
except OSError:
|
|
293
|
+
return ""
|
|
294
|
+
return ", ".join(f"{n} {k}" for n, k in ((sym, ".kicad_sym"), (fp, ".pretty"), (al, "Altium libs")) if n) or "no library files at the top"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def check_paths(libs=(), rules=None):
|
|
298
|
+
out = []
|
|
299
|
+
ag = (rules or {}).get("agent") or {}
|
|
300
|
+
named = [("lib", l, "FAIL") for l in libs] + [("agent.libs", l, "FAIL") for l in ag.get("libs") or []]
|
|
301
|
+
if ag.get("library_repo"):
|
|
302
|
+
named.append(("agent.library_repo", ag["library_repo"], "FAIL"))
|
|
303
|
+
for s in ((ag.get("watch") or {}).get("sources") or []):
|
|
304
|
+
if isinstance(s, dict) and s.get("type") in ("bom_file", "folder", "altium_project") and s.get("ref"):
|
|
305
|
+
named.append((f"watch {s['type']}", s["ref"], "WARN"))
|
|
306
|
+
for what, l, bad in named:
|
|
307
|
+
p = Path(str(l)).expanduser()
|
|
308
|
+
if p.exists():
|
|
309
|
+
out.append(_c(what, "OK", f"{p}" + (f" ({_lib_summary(p)})" if p.is_dir() and what != "agent.library_repo" else "")))
|
|
310
|
+
else:
|
|
311
|
+
out.append(_c(what, bad, f"{p}: not found", "fix the path (relative paths start at the current folder)"))
|
|
312
|
+
if not libs:
|
|
313
|
+
out.append(_c("lib", "OK", "no library folder of yours given: ecadlib builds a new library (to reuse your parts, "
|
|
314
|
+
"add your folders on the setup page or with `ecadlib init --lib FOLDER`)"))
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
MENU_INI = "ecadlib_menu.ini"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def find_menu_ini(given=None, cwd="."):
|
|
322
|
+
"""The ecadlib_menu.ini to check: --altium-menu (the file or its folder), else one in the current folder or in
|
|
323
|
+
out/altium_menu (`ecadlib altium-menu build`'s default --out). None when there is none."""
|
|
324
|
+
if given:
|
|
325
|
+
p = Path(given).expanduser()
|
|
326
|
+
return p / MENU_INI if p.is_dir() else p
|
|
327
|
+
return next((p for p in (Path(cwd) / MENU_INI, Path(cwd) / "out" / "altium_menu" / MENU_INI) if p.is_file()), None)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def split_command(cmd):
|
|
331
|
+
"""'"C:\\x y\\python.exe" -m ecadlib' -> ['C:\\x y\\python.exe', '-m', 'ecadlib'] (cmd.exe's quoting: a quoted
|
|
332
|
+
program, then words)."""
|
|
333
|
+
cmd = cmd.strip()
|
|
334
|
+
if cmd.startswith('"'):
|
|
335
|
+
end = cmd.find('"', 1)
|
|
336
|
+
if end < 0:
|
|
337
|
+
return [cmd[1:]]
|
|
338
|
+
return [cmd[1:end]] + cmd[end + 1:].split()
|
|
339
|
+
return cmd.split()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def check_altium_menu(ini, run=subprocess.run, which=shutil.which):
|
|
343
|
+
"""command= of the Altium menu's settings: empty (the runner searches PATH, py -3, python, which misses a venv),
|
|
344
|
+
a program that does not exist, or one that does not run ecadlib."""
|
|
345
|
+
from .altium_menu import default_command, load_command
|
|
346
|
+
ini = Path(ini)
|
|
347
|
+
example = default_command() or '"C:\\...\\python.exe" -m ecadlib'
|
|
348
|
+
fix = (f"set command={example} in {ini}, or run `ecadlib altium-menu build --out <that folder>` again with the "
|
|
349
|
+
"Python that has ecadlib (it fills an empty command=)")
|
|
350
|
+
if not ini.is_file():
|
|
351
|
+
return _c("altium menu", "FAIL", f"{ini}: not found", "give the ecadlib_menu.ini of your menu folder")
|
|
352
|
+
cmd = load_command(ini)
|
|
353
|
+
if not cmd:
|
|
354
|
+
return _c("altium menu", "WARN", f"{ini}: command= is empty, so the menu looks for ecadlib on PATH, then py -3, then "
|
|
355
|
+
"python; an install in a virtual environment (venv) is found by none of them", fix)
|
|
356
|
+
argv = split_command(cmd)
|
|
357
|
+
exe = argv[0] if argv else ""
|
|
358
|
+
found = exe if (os.sep in exe or "/" in exe or "\\" in exe) and Path(exe).is_file() else which(exe) if exe else None
|
|
359
|
+
if not found:
|
|
360
|
+
return _c("altium menu", "FAIL", f"{ini}: command= {cmd}: {exe or '(nothing)'} does not exist", fix)
|
|
361
|
+
try:
|
|
362
|
+
r = run(argv + ["--version"], capture_output=True, text=True, timeout=60)
|
|
363
|
+
out = ((r.stdout or "") + (r.stderr or "")).strip()
|
|
364
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
365
|
+
return _c("altium menu", "FAIL", f"{ini}: command= {cmd} did not run ({e})", fix)
|
|
366
|
+
if r.returncode != 0 or "ecadlib" not in out:
|
|
367
|
+
return _c("altium menu", "FAIL", f"{ini}: command= {cmd} does not run ecadlib: {out.splitlines()[-1] if out else r.returncode}", fix)
|
|
368
|
+
return _c("altium menu", "OK", f"{ini}: command= {cmd} ({out.splitlines()[-1]})")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def menu_workspace(ini):
|
|
372
|
+
"""workspace= of an ecadlib_menu.ini, resolved against its folder as the menu resolves it; None when unreadable."""
|
|
373
|
+
import configparser
|
|
374
|
+
try:
|
|
375
|
+
from .altium_menu import load_config
|
|
376
|
+
return load_config(ini)["workspace"] if Path(ini).is_file() else None
|
|
377
|
+
except (OSError, KeyError, ValueError, ImportError, configparser.Error):
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def run(workspace=None, libs=(), rules_path=None, environ=None, altium_menu=None):
|
|
382
|
+
"""workspace: None = the workspace= of the --altium-menu ini when one is given, else $ECADLIB_WORKSPACE, else
|
|
383
|
+
./ecadlib_workspace."""
|
|
384
|
+
menu = find_menu_ini(altium_menu)
|
|
385
|
+
if workspace is None:
|
|
386
|
+
env = os.environ if environ is None else environ
|
|
387
|
+
workspace = (altium_menu and menu is not None and menu_workspace(menu)) or env.get("ECADLIB_WORKSPACE") or "ecadlib_workspace"
|
|
388
|
+
workspace = str(workspace)
|
|
389
|
+
rc, rules = check_rules(rules_path, workspace if Path(workspace).is_dir() else None)
|
|
390
|
+
checks = [check_python()] + check_packages(rules=rules)
|
|
391
|
+
checks.append(rc)
|
|
392
|
+
checks += check_ai(rules, environ)
|
|
393
|
+
checks += check_keys(rules, environ)
|
|
394
|
+
checks.append(check_workspace(workspace))
|
|
395
|
+
if Path(workspace).is_dir():
|
|
396
|
+
checks.append(check_formats(workspace))
|
|
397
|
+
checks += check_paths(libs, rules)
|
|
398
|
+
if menu is not None:
|
|
399
|
+
checks.append(check_altium_menu(menu))
|
|
400
|
+
return {"ok": not any(c["level"] == "FAIL" for c in checks), "checks": checks}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def render(rep, styled=False, width=None, verbosity=0):
|
|
404
|
+
"""Outcome first, then what must be fixed, then what is optional, then what is fine (each with its fix on the next
|
|
405
|
+
line). styled: glyphs + colour (a terminal); -q: only the outcome and the problems."""
|
|
406
|
+
import io
|
|
407
|
+
from . import term as TM
|
|
408
|
+
buf = io.StringIO()
|
|
409
|
+
t = TM.Term(buf, styled=styled, width=width, verbosity=verbosity)
|
|
410
|
+
n = {lv: sum(c["level"] == lv for c in rep["checks"]) for lv in LEVELS}
|
|
411
|
+
st = {"FAIL": "failed", "WARN": "warn", "OK": "ok"}
|
|
412
|
+
head = ("Ready to use." if not n["FAIL"] else f"Not ready: {n['FAIL']} problem{'s' if n['FAIL'] != 1 else ''} to fix first.") \
|
|
413
|
+
+ (f" {n['WARN']} optional thing{'s' if n['WARN'] != 1 else ''} not set up." if n["WARN"] else "")
|
|
414
|
+
t.write(t.bold(head))
|
|
415
|
+
for lv, title in (("FAIL", "Fix these:"), ("WARN", "Optional (works without them):"), ("OK", "Fine:")):
|
|
416
|
+
group = [c for c in rep["checks"] if c["level"] == lv]
|
|
417
|
+
if not group or (lv == "OK" and t.quiet):
|
|
418
|
+
continue
|
|
419
|
+
t.write("\n" + title)
|
|
420
|
+
rows = []
|
|
421
|
+
for c in group:
|
|
422
|
+
rows.append([t.status(st.get(lv, "info"), lv) if styled else lv, c["check"], c["detail"]])
|
|
423
|
+
if c["hint"] and lv != "OK":
|
|
424
|
+
rows.append(["", "", f"-> {c['hint']}"])
|
|
425
|
+
t.table(rows, indent=0)
|
|
426
|
+
t.write(f"\n{n['OK']} ok, {n['WARN']} warning(s), {n['FAIL']} failure(s)")
|
|
427
|
+
return buf.getvalue().rstrip("\n")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def main(argv=None):
|
|
431
|
+
ap = argparse.ArgumentParser(prog="ecadlib doctor", description="check this machine's ecadlib setup (OK / WARN / FAIL)")
|
|
432
|
+
ap.add_argument("--workspace", help="workspace to check (default: the --altium-menu ini's workspace=, else "
|
|
433
|
+
"$ECADLIB_WORKSPACE, else ./ecadlib_workspace)")
|
|
434
|
+
ap.add_argument("--lib", action="append", default=[], help="KiCad library folder to check (repeatable; default $ECADLIB_LIB)")
|
|
435
|
+
ap.add_argument("--rules", help="rules file to check (default: the workspace's, then ECADLIB_CONFIG, then the search)")
|
|
436
|
+
ap.add_argument("--altium-menu", metavar="INI", help="the Altium menu's ecadlib_menu.ini (or its folder) to check "
|
|
437
|
+
"(default: one in the current folder or out/altium_menu)")
|
|
438
|
+
ap.add_argument("--json", action="store_true", help='print {"ok", "checks": [{"check", "level", "detail", "hint"}]}')
|
|
439
|
+
a = ap.parse_args(argv)
|
|
440
|
+
rep = run(a.workspace, a.lib, a.rules, altium_menu=a.altium_menu)
|
|
441
|
+
if a.json:
|
|
442
|
+
print(json.dumps(rep, indent=2))
|
|
443
|
+
else:
|
|
444
|
+
from . import term as TM
|
|
445
|
+
print(render(rep, TM.wants_style(sys.stdout), TM.term_width(sys.stdout), TM.VERBOSITY))
|
|
446
|
+
return 0 if rep["ok"] else 1
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
sys.exit(main())
|