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/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())