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/altium_menu.py ADDED
@@ -0,0 +1,1751 @@
1
+ """An "ecadlib" menu inside Altium Designer: a DelphiScript script project that calls ecadlib on your machine.
2
+
3
+ ecadlib altium-menu build --out D:/ecadlib-menu [--workspace D:/ecadlib-ws] [--to D:/libs/ecadlib]
4
+ [--command "C:\\Python311\\python.exe -m ecadlib"] [--win-dir D:\\ecadlib-menu]
5
+ ecadlib altium-menu add-part MPN [--manufacturer M] [--workspace W] [--to DIR] [--result-file F] [--json]
6
+ ecadlib altium-menu check-bom BOARD.PrjPcb [--workspace W] [--to DIR] [--no-open] [--result-file F]
7
+ ecadlib altium-menu fill BOARD.PrjPcb|BOM.csv [--workspace W] [--to DIR] [--result-file F]
8
+ ecadlib altium-menu queue [--workspace W] [--port 8765] [--result-file F]
9
+ ecadlib altium-menu run-job JOBFOLDER what the menu runs: reads JOBFOLDER/request.ini, writes result.ini
10
+
11
+ `build` writes into --out:
12
+ ecadlib_menu.PrjScr + ecadlib_menu.pas the script project; three procedures without parameters (so Altium's
13
+ Customize dialog and File > Run Script list them):
14
+ AddPartFromMPN asks for an MPN (+ optional manufacturer), builds the part, reinstalls the library
15
+ CheckProjectBOM checks the focused project's BOM, opens the report, offers to fill the library
16
+ OpenReviewQueue opens the ecadlib web UI at the review queue (starts `ecadlib ui` when it is not running)
17
+ ecadlib_dialog.pas + .dfm the ecadlib dialog (ecadlib.altium_forms): dark, like Altium's own, with
18
+ live progress read from the job's progress.ini; dialogs=0 in the .ini turns it off
19
+ ecadlib_menu.ini settings: workspace, library folder, command, hidden, timeout_s, port (kept when
20
+ it exists, so your edits survive a rebuild). On Windows, command= is the Python that
21
+ ran `build` (a venv's python.exe too): "<sys.executable>" -m ecadlib, unless --command
22
+ is given; a rebuild fills an empty command= the same way and changes nothing else
23
+ ecadlib_run.cmd + ecadlib_run.vbs how the script runs ecadlib: the .vbs starts the .cmd without a console window
24
+ (WScript.Shell.Run, window style 0) and the .cmd finds ecadlib (command= in the
25
+ .ini, else `ecadlib` on PATH, else `py -3 -m ecadlib`, else `python -m ecadlib`;
26
+ a venv install is found only through command=)
27
+ ecadlib_menu.DXPPrf optional: a preferences file that adds the "ecadlib" menu to the schematic, PCB,
28
+ SchLib and PcbLib editors (DXP > Preferences > Load, "System > Customizations"
29
+ only). Written only when the Windows folder is known (--win-dir, or running on
30
+ Windows): Altium's menu entries need the script's absolute path.
31
+ ALTIUM_MENU_README.txt the install steps
32
+
33
+ How a menu entry talks to ecadlib (no stdout parsing, no quoting of user text on a command line):
34
+ 1. the script makes jobs\\<id>\\, writes request.ini there ([request] action=add-part, mpn=..., config=...);
35
+ 2. RunApplication('wscript.exe //B //NoLogo "<dir>\\ecadlib_run.vbs" "<dir>\\ecadlib_run.cmd" "<job>"')
36
+ (or `cmd.exe /s /c ""<dir>\\ecadlib_run.cmd" "<job>""` with hidden=0), which runs
37
+ `ecadlib altium-menu run-job "<job>"` > log.txt and then writes exit.txt;
38
+ 3. the script waits for exit.txt (Sleep + FileExists, as Altium-Designer-addons' XIA_Utils.pas waits for its
39
+ svn runs) and reads result.ini ([result] status, message, fix, install, offer_fill, offer, report, url) with
40
+ TIniFile. result.ini is UTF-16 with a BOM, which Windows' profile API (behind TIniFile) reads in any code page.
41
+ 4. install= lists the DbLib(s) to reinstall: the script uninstalls/installs each with IntegratedLibraryManager and
42
+ runs IntegratedLibrary:RefreshInstalledLibraries (AllLibraries=True).
43
+
44
+ Where parts go: the workspace is an ordinary ecadlib workspace (`ecadlib ui --workspace` shows it); parts added from
45
+ the menu collect in <workspace>/uploads/altium-menu/parts.csv and the librarian runs on that list (finished parts are
46
+ kept). Ready parts are synced into the library folder (`ecadlib altium-pkg`: DbLib + SchLib/PcbLib + LibPkg), or,
47
+ when the rules name an Altium 365 workspace ([altium365] workspace), released there (`altium365 push`; NEXAR_TOKEN in
48
+ the environment): the Components panel then shows them after a refresh. Held parts are never published.
49
+
50
+ Never run inside Altium here: the script is written from Altium's scripting references (TR0120 DelphiScript,
51
+ TR0124 Server Process, TR0135 System, TR0136 Integrated Library, TR0140 Workspace Manager; 2008 PDFs kept in
52
+ github.com/Altium-Designer-addons/scripting-reference) and public scripts; see ALTIUM_API for each call it uses."""
53
+ import argparse, configparser, csv, datetime, html, io, json, os, re, subprocess, sys, time, zipfile
54
+ from pathlib import Path, PureWindowsPath
55
+
56
+ from . import __version__
57
+
58
+ PROJECT, UNIT, INI = "ecadlib_menu.PrjScr", "ecadlib_menu.pas", "ecadlib_menu.ini"
59
+ RUN_CMD, RUN_VBS, PREFS, README = "ecadlib_run.cmd", "ecadlib_run.vbs", "ecadlib_menu.DXPPrf", "ALTIUM_MENU_README.txt"
60
+ PROCS = [("AddPartFromMPN", "&Add part from MPN...", "Build a part from its manufacturer part number with ecadlib"),
61
+ ("CheckProjectBOM", "&Check this project BOM", "Check the focused project's BOM against the library; offer to fill it"),
62
+ ("OpenReviewQueue", "Open &review queue", "Open the ecadlib web UI at the review queue")]
63
+ MENU_BOM = "uploads/altium-menu/parts.csv"
64
+ HEADER = ["Designator", "Qty", "Value", "Footprint", "Manufacturer", "Mfr. Part #", "LCSC Part Number", "Description", "Datasheet"]
65
+ ACTIONS = ("add-part", "check-bom", "fill", "queue", "open")
66
+ QUEUE_ANCHOR = "#queuecard"
67
+
68
+ # Every identifier the .pas calls from Altium / DelphiScript, and where it is documented. TRxxxx = Altium's scripting
69
+ # reference PDFs (page numbers of github.com/Altium-Designer-addons/scripting-reference copies); "used in" = public
70
+ # Altium scripts that call it (github.com/Altium-Designer-addons/scripts-libraries, BrettLMiller/Altium-DelphiScripts),
71
+ # for the few calls the 2008 references do not list. tests/test_altium_menu.py checks the .pas uses nothing else.
72
+ ALTIUM_API = {
73
+ "RunApplication": "TR0135 System Reference p150 (ClientAPIReg: Function RunApplication(CommandLine) : Integer); TR0120 p18",
74
+ "ShowMessage": "TR0120 DelphiScript Reference p3",
75
+ "ShowError": "TR0120 p18 (Useful Dialogs)",
76
+ "ConfirmNoYes": "TR0120 p18 (Useful Dialogs); TR0135 p136",
77
+ "InputBox": "not in the 2008 references; used in BrettLMiller/Altium-DelphiScripts PCB/PolygonReFitBO.pas (InputBox(caption, prompt, default))",
78
+ "GetRunningScriptProjectName": "not in the 2008 references; used in Altium-Designer-addons scripts (4 files) and ecadlib altium-pkg's install script",
79
+ "GetWorkspace": "TR0140 Workspace Manager API p2",
80
+ "DM_FocusedProject": "TR0140 p57 (IWorkspace.DM_FocusedProject : IProject)",
81
+ "DM_ProjectFullPath": "TR0140 p28 (IProject.DM_ProjectFullPath)",
82
+ "DM_ProjectFileName": "TR0140 p28; 'Free Documents' check as in Altium-Designer-addons IncrementDesignators.pas",
83
+ "IntegratedLibraryManager": "TR0136 Integrated Library API p2",
84
+ "InstallLibrary": "TR0136 p2 (IIntegratedLibraryManager.InstallLibrary)",
85
+ "UninstallLibrary": "TR0136 p4",
86
+ "InstalledLibraryPath": "TR0136 p4",
87
+ "InstalledLibraryCount": "TR0136 p19 (IIntegratedLibraryManager.InstalledLibraryCount: the Installed tab only; AvailableLibraryCount also counts project and search-path libraries, which InstalledLibraryPath cannot index)",
88
+ "ResetParameters": "TR0120 p18 (Extension Routines); TR0135 p143",
89
+ "AddStringParameter": "TR0135 p143",
90
+ "RunProcess": "TR0120 p18; TR0135 p143; process IntegratedLibrary:RefreshInstalledLibraries (AllLibraries=True) in TR0124 Server Process Reference p19",
91
+ "TIniFile": "TR0135 p191 (Helper classes: Create, ReadString, ReadInteger, WriteString)",
92
+ "ReadString": "TR0135 p191 (TIniFile)",
93
+ "ReadInteger": "TR0135 p191 (TIniFile)",
94
+ "WriteString": "TR0135 p191 (TIniFile)",
95
+ "TStringList": "TR0135 p191 (Helper classes); TR0120 p10",
96
+ "SaveToFile": "TR0135 p196 (TStringList)",
97
+ "Add": "TStringList.Add, TR0120 p10",
98
+ "Create": "constructor of TIniFile / TStringList, TR0135 p191",
99
+ "Free": "destructor, TR0120 p10",
100
+ "FileExists": "TR0135 p143",
101
+ "DirectoryExists": "TR0135 p145",
102
+ "MkDir": "TR0120 p16 (File IO Routines)",
103
+ "ExtractFilePath": "TR0136 p29",
104
+ "Now": "TR0120 p8",
105
+ "FormatDateTime": "not in the 2008 references; used in Altium-Designer-addons XIA_Generate_Sorted_Multiwire_Netlist.pas (FormatDateTime('yyyy-mm-dd_hh_nn_ss', Now()))",
106
+ "Random": "TR0120 p17 (Math Routines)",
107
+ "Randomize": "TR0120 p17 (Math Routines)",
108
+ "IntToStr": "TR0120 p6",
109
+ "Trim": "TR0120 p17 (String Routines)",
110
+ "Pos": "TR0120 p17 (String Routines)",
111
+ "Copy": "TR0120 p17 (String Routines)",
112
+ "Length": "TR0120 p17 (String Routines)",
113
+ "UpperCase": "TR0120 p17 (String Routines)",
114
+ "Sleep": "not in the 2008 references; used in Altium-Designer-addons XIA_Utils.pas (waits for an external run's result file) and QuickSilk.pas",
115
+ "Application": "Application.ProcessMessages: not in the 2008 references; used in Altium-Designer-addons AutoPlaceSilkscreen.pas and AssemblyTextPrep.pas",
116
+ "ProcessMessages": "see Application",
117
+ }
118
+
119
+
120
+ # ------------------------------------------------------------------ the script project
121
+ def pas_text():
122
+ """The DelphiScript unit (CRLF). Static: everything that differs per install is in ecadlib_menu.ini."""
123
+ return r"""{ ecadlib menu for Altium Designer (written by `ecadlib altium-menu build`, ecadlib @VERSION@).
124
+
125
+ Three entries, each a procedure without parameters (Customize > [Scripts] and File > Run Script list them):
126
+ AddPartFromMPN asks for a manufacturer part number, builds it with ecadlib into your library folder (or
127
+ Altium 365 when ecadlib.toml names a workspace) and reinstalls the database library
128
+ CheckProjectBOM checks the BOM of the focused project, opens the report and offers to fill the library
129
+ OpenReviewQueue opens the ecadlib web UI at the review queue (starts it when it is not running)
130
+
131
+ How: each entry writes jobs\<id>\request.ini next to this script, runs ecadlib_run.cmd (without a console window
132
+ through ecadlib_run.vbs unless hidden=0 in ecadlib_menu.ini), waits for jobs\<id>\exit.txt and reads
133
+ jobs\<id>\result.ini, which `ecadlib altium-menu run-job` writes. Settings: ecadlib_menu.ini. No keys or passwords
134
+ are in these files: API keys stay in your environment variables. }
135
+
136
+ Function Here : String;
137
+ Begin
138
+ Result := ExtractFilePath(GetRunningScriptProjectName);
139
+ End;
140
+
141
+ Function Setting(Key : String; Fallback : String) : String;
142
+ Var
143
+ Ini : TIniFile;
144
+ Begin
145
+ Ini := TIniFile.Create(Here + 'ecadlib_menu.ini');
146
+ Result := Ini.ReadString('ecadlib', Key, Fallback);
147
+ Ini.Free;
148
+ End;
149
+
150
+ Function SettingNumber(Key : String; Fallback : Integer) : Integer;
151
+ Var
152
+ Ini : TIniFile;
153
+ Begin
154
+ Ini := TIniFile.Create(Here + 'ecadlib_menu.ini');
155
+ Result := Ini.ReadInteger('ecadlib', Key, Fallback);
156
+ Ini.Free;
157
+ End;
158
+
159
+ Procedure Ask(Job : String; Key : String; Value : String);
160
+ Var
161
+ Ini : TIniFile;
162
+ Begin
163
+ Ini := TIniFile.Create(Job + '\request.ini');
164
+ Ini.WriteString('request', Key, Value);
165
+ Ini.Free;
166
+ End;
167
+
168
+ Function Answer(Job : String; Key : String) : String;
169
+ Var
170
+ Ini : TIniFile;
171
+ Begin
172
+ Ini := TIniFile.Create(Job + '\result.ini');
173
+ Result := Ini.ReadString('result', Key, '');
174
+ Ini.Free;
175
+ End;
176
+
177
+ Function StartJob(Action : String) : String;
178
+ Var
179
+ Cmd : String;
180
+ L : TStringList;
181
+ Begin
182
+ If Not DirectoryExists(Here + 'jobs') Then
183
+ MkDir(Here + 'jobs');
184
+ Randomize;
185
+ Result := Here + 'jobs\' + FormatDateTime('yyyymmdd-hhnnss', Now) + '-' + IntToStr(Random(100000));
186
+ MkDir(Result);
187
+ Ask(Result, 'action', Action);
188
+ Ask(Result, 'config', Here + 'ecadlib_menu.ini');
189
+ Cmd := Trim(Setting('command', ''));
190
+ If Cmd <> '' Then
191
+ Begin
192
+ L := TStringList.Create;
193
+ L.Add(Cmd);
194
+ L.SaveToFile(Result + '\command.txt');
195
+ L.Free;
196
+ End;
197
+ End;
198
+
199
+ Function CommandLine(Job : String) : String;
200
+ Begin
201
+ If Setting('hidden', '1') = '0' Then
202
+ Result := 'cmd.exe /s /c ""' + Here + 'ecadlib_run.cmd" "' + Job + '""'
203
+ Else
204
+ Result := 'wscript.exe //B //NoLogo "' + Here + 'ecadlib_run.vbs" "' + Here + 'ecadlib_run.cmd" "' + Job + '"';
205
+ End;
206
+
207
+ Function RunJob(Job : String; Title : String) : String;
208
+ { Runs the job and waits for it. Returns '' when ecadlib wrote result.ini, else the problem in one line. }
209
+ Var
210
+ Code : Integer;
211
+ Waited : Integer;
212
+ Limit : Integer;
213
+ Cmd : String;
214
+ Begin
215
+ Result := '';
216
+ Limit := SettingNumber('timeout_s', 1800) * 1000;
217
+ Cmd := CommandLine(Job);
218
+ Code := RunApplication(Cmd);
219
+ If Code <> 0 Then
220
+ Begin
221
+ Result := 'Windows could not start ecadlib (error ' + IntToStr(Code) + ').' + #13#10 + 'Next: set hidden=0 in ' + Here + 'ecadlib_menu.ini and try again.';
222
+ Exit;
223
+ End;
224
+ Waited := 0;
225
+ While Not FileExists(Job + '\exit.txt') Do
226
+ Begin
227
+ Sleep(200);
228
+ Application.ProcessMessages;
229
+ Waited := Waited + 200;
230
+ If Waited >= Limit Then
231
+ Begin
232
+ If ConfirmNoYes(Title + ': ecadlib is still working. Keep waiting?') Then
233
+ Waited := 0
234
+ Else
235
+ Begin
236
+ Result := 'Stopped waiting. ecadlib may still finish in the background.' + #13#10 + 'Next: look at its log, ' + Job + '\log.txt';
237
+ Exit;
238
+ End;
239
+ End;
240
+ End;
241
+ Sleep(300);
242
+ If FileExists(Job + '\result.ini') Then
243
+ Exit;
244
+ If FileExists(Job + '\notfound.txt') Then
245
+ Result := 'ecadlib is not installed on this PC (not on PATH, and no Python can import it).' + #13#10 + 'Next: pip install ecadlib, or set command= in ' + Here + 'ecadlib_menu.ini'
246
+ Else
247
+ Result := 'ecadlib stopped without a result.' + #13#10 + 'Next: look at its log, ' + Job + '\log.txt';
248
+ End;
249
+
250
+ Function IsInstalled(Path : String) : Boolean;
251
+ Var
252
+ i : Integer;
253
+ Begin
254
+ Result := False;
255
+ For i := 0 To IntegratedLibraryManager.InstalledLibraryCount - 1 Do
256
+ If UpperCase(IntegratedLibraryManager.InstalledLibraryPath(i)) = UpperCase(Path) Then
257
+ Result := True;
258
+ End;
259
+
260
+ Function Reinstall(Job : String) : String;
261
+ { Uninstalls and installs again every library result.ini names in install= (';' between paths), then refreshes. }
262
+ Var
263
+ List : String;
264
+ P : String;
265
+ i : Integer;
266
+ Begin
267
+ Result := '';
268
+ List := Answer(Job, 'install');
269
+ While List <> '' Do
270
+ Begin
271
+ i := Pos(';', List);
272
+ If i = 0 Then
273
+ Begin
274
+ P := List;
275
+ List := '';
276
+ End
277
+ Else
278
+ Begin
279
+ P := Copy(List, 1, i - 1);
280
+ List := Copy(List, i + 1, Length(List));
281
+ End;
282
+ If P <> '' Then
283
+ Begin
284
+ If FileExists(P) Then
285
+ Begin
286
+ If IsInstalled(P) Then
287
+ IntegratedLibraryManager.UninstallLibrary(P);
288
+ IntegratedLibraryManager.InstallLibrary(P);
289
+ Result := Result + #13#10 + 'Installed again: ' + P;
290
+ End
291
+ Else
292
+ Result := Result + #13#10 + 'Missing: ' + P;
293
+ End;
294
+ End;
295
+ ResetParameters;
296
+ AddStringParameter('AllLibraries', 'True');
297
+ RunProcess('IntegratedLibrary:RefreshInstalledLibraries');
298
+ End;
299
+
300
+ Function MessageText(Job : String; Extra : String) : String;
301
+ Begin
302
+ Result := Answer(Job, 'message');
303
+ If Answer(Job, 'fix') <> '' Then
304
+ Result := Result + #13#10 + #13#10 + 'Next: ' + Answer(Job, 'fix');
305
+ If Extra <> '' Then
306
+ Result := Result + #13#10 + Extra;
307
+ End;
308
+
309
+ Procedure ShowResult(Title : String; Job : String; Extra : String);
310
+ Begin
311
+ If Answer(Job, 'status') = 'error' Then
312
+ ShowError(Title + #13#10 + #13#10 + MessageText(Job, Extra))
313
+ Else
314
+ ShowMessage(Title + #13#10 + #13#10 + MessageText(Job, Extra));
315
+ End;
316
+
317
+ Function Publish(Job : String) : String;
318
+ Begin
319
+ Result := '';
320
+ If Answer(Job, 'install') <> '' Then
321
+ Result := Reinstall(Job);
322
+ End;
323
+
324
+ Function PlainDialogs : Boolean;
325
+ { dialogs=0 in ecadlib_menu.ini: Altium's plain message boxes instead of the ecadlib dialog. }
326
+ Begin
327
+ Result := Setting('dialogs', '1') = '0';
328
+ End;
329
+
330
+ Procedure AddPartFromMPN;
331
+ Var
332
+ MPN : String;
333
+ Maker : String;
334
+ Job : String;
335
+ Problem : String;
336
+ Title : String;
337
+ Begin
338
+ If Not PlainDialogs Then
339
+ Begin
340
+ OpenDialog('add', '', '');
341
+ Exit;
342
+ End;
343
+ MPN := Trim(InputBox('ecadlib: add part', 'Manufacturer part number:', ''));
344
+ If MPN = '' Then
345
+ Exit;
346
+ Maker := Trim(InputBox('ecadlib: add part', 'Manufacturer of ' + MPN + ' (optional, leave empty if unsure):', ''));
347
+ Title := 'ecadlib: add ' + MPN;
348
+ Job := StartJob('add-part');
349
+ Ask(Job, 'mpn', MPN);
350
+ Ask(Job, 'manufacturer', Maker);
351
+ Problem := RunJob(Job, Title);
352
+ If Problem <> '' Then
353
+ Begin
354
+ ShowError(Title + #13#10 + #13#10 + Problem);
355
+ Exit;
356
+ End;
357
+ ShowResult(Title, Job, Publish(Job));
358
+ End;
359
+
360
+ Procedure CheckProjectBOM;
361
+ Var
362
+ Prj : IProject;
363
+ PrjPath : String;
364
+ Job : String;
365
+ Problem : String;
366
+ Title : String;
367
+ Begin
368
+ Title := 'ecadlib: check this project BOM';
369
+ Prj := GetWorkspace.DM_FocusedProject;
370
+ If Prj = Nil Then
371
+ Begin
372
+ ShowError(Title + #13#10 + #13#10 + 'Open a PCB project first (click it in the Projects panel).');
373
+ Exit;
374
+ End;
375
+ If Prj.DM_ProjectFileName = 'Free Documents' Then
376
+ Begin
377
+ ShowError(Title + #13#10 + #13#10 + 'Open a PCB project first (click it in the Projects panel).');
378
+ Exit;
379
+ End;
380
+ PrjPath := Prj.DM_ProjectFullPath;
381
+ If Not PlainDialogs Then
382
+ Begin
383
+ OpenDialog('bom', PrjPath, '');
384
+ Exit;
385
+ End;
386
+ Job := StartJob('check-bom');
387
+ Ask(Job, 'project', PrjPath);
388
+ Problem := RunJob(Job, Title);
389
+ If Problem <> '' Then
390
+ Begin
391
+ ShowError(Title + #13#10 + #13#10 + Problem);
392
+ Exit;
393
+ End;
394
+ If Answer(Job, 'offer_fill') <> '1' Then
395
+ Begin
396
+ ShowResult(Title, Job, '');
397
+ Exit;
398
+ End;
399
+ If Not ConfirmNoYes(MessageText(Job, '') + #13#10 + #13#10 + Answer(Job, 'offer')) Then
400
+ Exit;
401
+ Title := 'ecadlib: fill the library';
402
+ Job := StartJob('fill');
403
+ Ask(Job, 'project', PrjPath);
404
+ Problem := RunJob(Job, Title);
405
+ If Problem <> '' Then
406
+ Begin
407
+ ShowError(Title + #13#10 + #13#10 + Problem);
408
+ Exit;
409
+ End;
410
+ ShowResult(Title, Job, Publish(Job));
411
+ End;
412
+
413
+ Procedure OpenReviewQueue;
414
+ Var
415
+ Job : String;
416
+ Problem : String;
417
+ Title : String;
418
+ Begin
419
+ Title := 'ecadlib: review queue';
420
+ Job := StartJob('queue');
421
+ Problem := RunJob(Job, Title);
422
+ If Problem <> '' Then
423
+ Begin
424
+ ShowError(Title + #13#10 + #13#10 + Problem);
425
+ Exit;
426
+ End;
427
+ If Answer(Job, 'status') <> 'ok' Then
428
+ ShowResult(Title, Job, '');
429
+ End;
430
+ """.replace("@VERSION@", __version__).replace("\n", "\r\n")
431
+
432
+
433
+ def run_cmd_text():
434
+ """ecadlib_run.cmd JOBFOLDER: find ecadlib, run the job, write exit.txt (and notfound.txt when there is none)."""
435
+ return r"""@echo off
436
+ rem ecadlib menu runner (written by `ecadlib altium-menu build`). Usage: ecadlib_run.cmd JOBFOLDER
437
+ rem Finds ecadlib: command.txt in the job (command= in ecadlib_menu.ini), else ecadlib on PATH, else py -3 / python
438
+ rem that can import it. Runs `ecadlib altium-menu run-job JOBFOLDER` (which writes result.ini), then exit.txt.
439
+ setlocal
440
+ set "JOB=%~1"
441
+ set "ECADLIB="
442
+ if exist "%JOB%\command.txt" set /p ECADLIB=<"%JOB%\command.txt"
443
+ if defined ECADLIB goto run
444
+ where ecadlib >nul 2>nul && set "ECADLIB=ecadlib"
445
+ if defined ECADLIB goto run
446
+ py -3 -c "import ecadlib" >nul 2>nul && set "ECADLIB=py -3 -m ecadlib"
447
+ if defined ECADLIB goto run
448
+ python -c "import ecadlib" >nul 2>nul && set "ECADLIB=python -m ecadlib"
449
+ if defined ECADLIB goto run
450
+ > "%JOB%\log.txt" echo ecadlib was not found: not on PATH and no Python that can import it.
451
+ > "%JOB%\notfound.txt" echo 9009
452
+ > "%JOB%\exit.txt" echo 9009
453
+ exit /b 9009
454
+ :run
455
+ %ECADLIB% altium-menu run-job "%JOB%" > "%JOB%\log.txt" 2>&1
456
+ > "%JOB%\exit.txt" echo %ERRORLEVEL%
457
+ exit /b 0
458
+ """.replace("\n", "\r\n")
459
+
460
+
461
+ def run_vbs_text():
462
+ """wscript ecadlib_run.vbs RUNNER.cmd JOBFOLDER: run the .cmd with no window (style 0) and wait for it."""
463
+ return """' ecadlib menu: runs ecadlib_run.cmd without a console window and waits (written by `ecadlib altium-menu build`).
464
+ ' Usage: wscript.exe //B //NoLogo ecadlib_run.vbs RUNNER.cmd JOBFOLDER
465
+ q = Chr(34)
466
+ line = "cmd.exe /s /c " & q & q & WScript.Arguments(0) & q & " " & q & WScript.Arguments(1) & q & q
467
+ CreateObject("WScript.Shell").Run line, 0, True
468
+ """.replace("\n", "\r\n")
469
+
470
+
471
+ def vbs_line(runner, job):
472
+ """The command line ecadlib_run.vbs hands to WScript.Shell.Run (Python mirror of its one expression, for tests)."""
473
+ q = '"'
474
+ return "cmd.exe /s /c " + q + q + runner + q + " " + q + job + q + q
475
+
476
+
477
+ def script_command_line(here, job, hidden=True):
478
+ """The command line the .pas passes to RunApplication (Python mirror of CommandLine(), for tests). `here` ends
479
+ with a backslash (ExtractFilePath); `job` does not."""
480
+ if not hidden:
481
+ return f'cmd.exe /s /c ""{here}{RUN_CMD}" "{job}""'
482
+ return f'wscript.exe //B //NoLogo "{here}{RUN_VBS}" "{here}{RUN_CMD}" "{job}"'
483
+
484
+
485
+ def ini_text(workspace="workspace", library="library", name="ecadlib", command="", hidden=True, timeout_s=1800,
486
+ port=8765, offline=False):
487
+ return f"""; ecadlib menu settings (read by ecadlib_menu.pas and `ecadlib altium-menu run-job`).
488
+ ; Relative paths start at this folder. Edit freely: `ecadlib altium-menu build` keeps this file.
489
+ [ecadlib]
490
+ ; the ecadlib workspace: parts added from the menu, reports, the review queue (`ecadlib ui --workspace` it)
491
+ workspace={workspace}
492
+ ; the Altium library folder ready parts are synced into (DbLib + SchLib\\ + PcbLib\\ + .LibPkg); not used when
493
+ ; the workspace's ecadlib.toml names an Altium 365 workspace ([altium365] workspace)
494
+ library={library}
495
+ ; library name (the .DbLib / .LibPkg file name)
496
+ name={name}
497
+ ; how to run ecadlib. `ecadlib altium-menu build` on Windows writes the Python it ran with (a venv's too).
498
+ ; empty = ecadlib on PATH, else py -3 -m ecadlib, else python -m ecadlib (a venv install is not found that way)
499
+ ; example: command="C:\\Program Files\\Python311\\python.exe" -m ecadlib
500
+ command={command}
501
+ ; 1 = no console window (wscript + ecadlib_run.vbs), 0 = run ecadlib_run.cmd in a console window
502
+ hidden={1 if hidden else 0}
503
+ ; seconds to wait before asking whether to keep waiting
504
+ timeout_s={int(timeout_s)}
505
+ ; port of the local web UI (Open review queue)
506
+ port={int(port)}
507
+ ; 1 = use only cached downloads and built-in standard parts (no network)
508
+ offline={1 if offline else 0}
509
+ ; 1 = the ecadlib dialog (dark, live progress, like Altium's own); 0 = Altium's plain message boxes
510
+ dialogs=1
511
+ """.replace("\n", "\r\n")
512
+
513
+
514
+ # ------------------------------------------------------------------ the optional menu (DXP.RCS in a .DXPPrf)
515
+ # Menus to extend and the item to insert before (their Window menu), as Altium-Designer-addons' Menu_SVN DXP.RCS does.
516
+ MENUS = [("SchDoc", "MNSchematicMenu", "MNDesignMenu_Window1_3", "SchCustom"),
517
+ ("PcbDoc", "MNPCBMenu", "MNDesignMenu_Window1_1", "PCBCustom"),
518
+ ("SchLib", "MNSchLibMenu", "MNDesignMenu_Window1_4", "SchLibCustom"),
519
+ ("PcbLib", "MNPCBLibMenu", "MNDesignMenu_Window1_2", "PCBLibCustom")]
520
+
521
+
522
+ def check_win_dir(win_dir):
523
+ w = str(win_dir).rstrip("\\/")
524
+ if not PureWindowsPath(w).is_absolute() or not re.match(r"^([A-Za-z]:\\|\\\\)", w.replace("/", "\\")):
525
+ raise ValueError(f"--win-dir {win_dir!r}: give the absolute Windows folder the menu files will be in (D:\\ecadlib-menu)")
526
+ if any(c in w for c in "'|\"\r\n"):
527
+ raise ValueError(f"--win-dir {win_dir!r}: a menu entry cannot hold ' | or \" in its path; use another folder")
528
+ return w.replace("/", "\\")
529
+
530
+
531
+ def rcs_text(win_dir):
532
+ """DXP.RCS that adds an "ecadlib" menu (format of Altium-Designer-addons UI customizations/Menu_SVN/DXP.RCS)."""
533
+ w = check_win_dir(win_dir)
534
+ L = []
535
+ for proc, caption, desc in PROCS:
536
+ L.append(f"PL ecadlib_{proc} Command='ScriptingSystem:RunScript' Params='ProjectName={w}\\{PROJECT}|ProcName="
537
+ f"{w}\\{UNIT}>{proc}' Caption='{caption}' Description='{desc.replace(chr(39), '')}' DefaultChecked=0 End")
538
+ L.append("")
539
+ for kind, target, ref, _ in MENUS:
540
+ L += [f"Insertion {kind}_ecadlib_Menu TargetID='{target}' RefID0='{ref}' InsertType='Before' ",
541
+ f" Tree {kind}_ecadlib_Tree Caption='&ecadlib' Popup='N' "]
542
+ L += [f" Link {kind}_ecadlib_{proc} PLID='ecadlib_{proc}' End" for proc, _, _ in PROCS]
543
+ L += [" End", "End"]
544
+ L.append("")
545
+ for kind, _, _, custom in MENUS:
546
+ L.append(f"Tree {custom} Caption='[Custom]' TopLevel='True' ")
547
+ L += [f" Link {custom}_ecadlib_{proc} PLID='ecadlib_{proc}' End" for proc, _, _ in PROCS]
548
+ L.append("End")
549
+ return "\r\n".join(L) + "\r\n"
550
+
551
+
552
+ # ------------------------------------------------------------------ the menu straight into Altium's DXP.RCS
553
+ def rcs_blocks(text):
554
+ """DXP.RCS -> [lines of each top-level block]. A line ending in End closes itself; Insertion / Tree / other lines
555
+ without one open a block that an End line closes (the grammar of Altium's .rcs resources)."""
556
+ blocks, cur, depth = [], [], 0
557
+ for ln in text.replace("\r\n", "\n").split("\n"):
558
+ if not ln.strip():
559
+ if depth == 0:
560
+ continue
561
+ cur.append(ln)
562
+ continue
563
+ cur.append(ln)
564
+ st = ln.strip()
565
+ if st == "End":
566
+ depth -= 1
567
+ elif not re.search(r"\bEnd\s*$", st):
568
+ depth += 1
569
+ if depth < 0:
570
+ raise ValueError(f"DXP.RCS: an End without its block near {st[:60]!r}")
571
+ if depth == 0:
572
+ blocks.append(cur)
573
+ cur = []
574
+ if depth or cur:
575
+ raise ValueError("DXP.RCS: a block is not closed (End missing)")
576
+ return blocks
577
+
578
+
579
+ def _block_id(block):
580
+ m = re.match(r"^\s*\w+\s+(\w+)", block[0])
581
+ return m.group(1) if m else ""
582
+
583
+
584
+ def merge_rcs(current, win_dir, remove=False):
585
+ """Altium's DXP.RCS with the ecadlib menu in it (or, remove=True, without it): ecadlib's commands (PL) and the
586
+ four menu Insertions. Earlier ecadlib entries are replaced, our links inside a [Custom] tree (from loading the
587
+ .DXPPrf) are taken out, and everything else is kept as it was. The [Custom] trees themselves are left alone."""
588
+ ours = [] if remove else [b for b in rcs_blocks(rcs_text(win_dir)) if not _block_id(b).endswith("Custom")]
589
+ mine = lambda i: i.startswith("ecadlib_") or "_ecadlib_" in i
590
+ out = []
591
+ for b in rcs_blocks(current or ""):
592
+ if mine(_block_id(b)):
593
+ continue
594
+ out.append([ln for ln in b if not mine(_block_id([ln]))])
595
+ out += ours
596
+ return "\r\n".join(ln for b in out for ln in b) + ("\r\n" if out else "")
597
+
598
+
599
+ def altium_user_folders(appdata=None):
600
+ """%APPDATA%\\Altium\\Altium Designer {GUID} folders (one per installed Altium), newest first."""
601
+ base = Path(appdata or os.environ.get("APPDATA", "")) / "Altium"
602
+ if not base.is_dir():
603
+ return []
604
+ found = [d for d in base.glob("Altium Designer {*}") if d.is_dir()]
605
+ return sorted(found, key=lambda d: (d / "DXP.RCS").stat().st_mtime if (d / "DXP.RCS").exists() else d.stat().st_mtime,
606
+ reverse=True)
607
+
608
+
609
+ def altium_running():
610
+ """True when an Altium Designer (X2.EXE) runs on this PC (it writes DXP.RCS when it closes)."""
611
+ if os.name != "nt":
612
+ return False
613
+ try:
614
+ out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq X2.EXE", "/NH"], capture_output=True, text=True,
615
+ timeout=20).stdout
616
+ except (OSError, subprocess.SubprocessError):
617
+ return False
618
+ return "x2.exe" in out.lower()
619
+
620
+
621
+ def install_menu_bar(menu_dir, folders=None, remove=False, running=altium_running, now=None, settle_s=5.0,
622
+ sleep=time.sleep):
623
+ """Put the "ecadlib" menu into Altium's top menu bar (schematic, PCB, SchLib and PcbLib editors, before Window)
624
+ by merging it into each Altium's DXP.RCS, after a backup next to it. Altium must be closed: it writes that
625
+ file when it closes. remove=True takes it out again. -> {"installed"|"removed": [DXP.RCS], "backups": [...]}."""
626
+ win = check_win_dir(str(Path(menu_dir).resolve()) if os.name == "nt" else str(menu_dir))
627
+ if running():
628
+ raise ValueError("Altium Designer is running: close it first (it rewrites its menu file when it closes), "
629
+ "then run this again")
630
+ folders = [Path(f) for f in folders] if folders else altium_user_folders()
631
+ if not folders:
632
+ raise ValueError("no Altium Designer settings folder found (%APPDATA%\\Altium\\Altium Designer {...}): start "
633
+ "Altium once, close it, then run this again")
634
+ stamp = (now or datetime.datetime.now()).strftime("%Y%m%d-%H%M%S")
635
+ done, backups = [], []
636
+ for f in folders:
637
+ rcs = f / "DXP.RCS"
638
+ cur = rcs.read_text(encoding="latin-1") if rcs.is_file() else ""
639
+ new = merge_rcs(cur, win, remove=remove)
640
+ if new.replace("\r\n", "\n").strip() == cur.replace("\r\n", "\n").strip():
641
+ continue
642
+ if rcs.is_file():
643
+ bak = f / f"DXP.RCS.before-ecadlib-{stamp}"
644
+ bak.write_bytes(rcs.read_bytes())
645
+ backups.append(str(bak))
646
+ rcs.write_bytes(new.encode("latin-1"))
647
+ done.append(str(rcs))
648
+ if done and settle_s: # an Altium still closing writes its old menus over ours: look again
649
+ sleep(settle_s)
650
+ lost = [r for r in done if Path(r).read_bytes() != merge_rcs(Path(r).read_text(encoding="latin-1"), win,
651
+ remove=remove).encode("latin-1")]
652
+ if lost or running():
653
+ raise ValueError("an Altium Designer that was still closing wrote its old menus back over the ecadlib menu"
654
+ f" ({', '.join(lost) or 'Altium is running again'}): wait until no X2.EXE is left in Task "
655
+ "Manager, then run this again")
656
+ return {"removed" if remove else "installed": done, "backups": backups, "folders": [str(f) for f in folders]}
657
+
658
+
659
+ def dxpprf_bytes(win_dir):
660
+ """A preferences file (zip of DXP.RCS + IncludedFiles.ini, as Menu_SVN.DXPPrf). Fixed timestamps: same bytes each run."""
661
+ buf = io.BytesIO()
662
+ with zipfile.ZipFile(buf, "w") as z:
663
+ for name, data, comp in (("DXP.RCS", rcs_text(win_dir).encode("ascii"), zipfile.ZIP_DEFLATED),
664
+ ("IncludedFiles.ini", b"[WorkspaceManager:Customizations]\r\nImportMode=0\r\nFileCount=1\r\n"
665
+ b"FileName0=DXP.RCS\r\n", zipfile.ZIP_STORED)):
666
+ zi = zipfile.ZipInfo(name, (2020, 1, 1, 0, 0, 0))
667
+ zi.compress_type = comp
668
+ z.writestr(zi, data)
669
+ return buf.getvalue()
670
+
671
+
672
+ def readme_text(win_dir, prefs):
673
+ w = win_dir or "<this folder>"
674
+ menu = (f""" Or load the ready-made menu: DXP (or the gear icon) > Preferences > Load... (bottom left) > {PREFS}; tick only
675
+ "System > Customizations", OK, restart Altium. CAUTION: this loads a customisation file, which may replace
676
+ menus you customised yourself (back them up first with Preferences > Save...). It names {w} in each entry: build
677
+ it again with --win-dir if you move this folder.""" if prefs else
678
+ f""" (No {PREFS} here: the Windows folder was not known. Run `ecadlib altium-menu build --win-dir {w}` on
679
+ this PC to get the ready-made menu file.)""")
680
+ return f"""ecadlib menu for Altium Designer (written by `ecadlib altium-menu build`, ecadlib {__version__})
681
+
682
+ What it adds: an "ecadlib" menu (or toolbar buttons) with three entries:
683
+ Add part from MPN asks for a manufacturer part number, builds the part, reinstalls the library
684
+ Check this project BOM checks the focused project's BOM: counts per kind, then Later / Fill the library now
685
+ Open review queue opens the ecadlib web UI at the review queue in your browser
686
+ The entries work in the ecadlib dialog (dark, like Altium's own, with live progress); you stay in Altium. Only the
687
+ review queue (a held part needs a person) and the full BOM report (on request) open in your browser. dialogs=0 in
688
+ {INI} brings back Altium's plain message boxes.
689
+
690
+ Needs: ecadlib installed on this PC (pip install ecadlib; `ecadlib doctor --altium-menu {INI}` checks it). Settings
691
+ are in {INI} (workspace, library folder, how to run ecadlib). Your API keys stay in environment variables; no file
692
+ here holds one.
693
+
694
+ How the menu runs ecadlib: command= in {INI}. `ecadlib altium-menu build` run on Windows writes the Python it ran
695
+ with there ("C:\\...\\python.exe" -m ecadlib), so an install in a virtual environment (venv) works. When command=
696
+ is empty, the runner tries `ecadlib` on PATH, then `py -3 -m ecadlib`, then `python -m ecadlib`; a venv is none of
697
+ those, and the menu then says "ecadlib was not found". Fix: run the build again from the venv (it fills an empty
698
+ command= and keeps your other settings), or set command= yourself.
699
+
700
+ Without an AI key (ANTHROPIC_API_KEY, or the model set in ecadlib.toml [ai]), ecadlib cannot read datasheets, so
701
+ parts no free source fully describes come back held with the reason ("fields: Description is empty", "footprint:
702
+ ..."). Held parts never go into the library: open the review queue (ecadlib > Open review queue) to see why and
703
+ pick or fill what is missing, or set the key (Windows: setx ANTHROPIC_API_KEY "<your key>" in a terminal, then
704
+ restart Altium so it sees the new variable) and add the part again.
705
+
706
+ Install (once), the quick way: close Altium, run `ecadlib altium-menu install-menu --out "{w}"` and start Altium
707
+ again: the "ecadlib" menu is in the menu bar of the schematic, PCB and library editors, before Window. It is merged
708
+ into Altium's own menu file (%APPDATA%\\Altium\\Altium Designer {{...}}\\DXP.RCS, backed up next to it first);
709
+ `--remove` takes it out again. The step-by-step way, without closing Altium:
710
+ 1. Altium: DXP (AD17) or the gear icon (AD18+) > Preferences > Scripting System > Global Projects > Install from
711
+ file... > {w}\\{PROJECT}. The three procedures now appear in File > Run Script... under {PROJECT}.
712
+ 2. Menu: right-click any menu or toolbar > Customize... > Categories: [Scripts] (or [Custom]) > drag
713
+ {UNIT}>AddPartFromMPN, >CheckProjectBOM, and >OpenReviewQueue onto a menu or toolbar; rename them in the entry's
714
+ properties. Repeat for each editor you want them in (schematic, PCB, library editors).
715
+ {menu}
716
+ 3. Try it: open a PCB project and pick ecadlib > Check this project BOM.
717
+
718
+ What happens on a click: the script writes jobs\\<id>\\request.ini next to it, runs {RUN_CMD} without a console
719
+ window (through {RUN_VBS}; set hidden=0 in {INI} to see the console); the dialog follows jobs\\<id>\\progress.ini
720
+ and reads jobs\\<id>\\result.ini at the end.
721
+ If something fails, the message names the fix and jobs\\<id>\\log.txt has ecadlib's output.
722
+
723
+ This script was written from Altium's scripting reference and public scripts and was never run inside Altium when it
724
+ was made: if a step fails, please report the message.
725
+ """.replace("\n", "\r\n")
726
+
727
+
728
+ def _short_path(p):
729
+ """Windows 8.3 short form of an existing path (ASCII even when the long one is not), else p."""
730
+ try:
731
+ import ctypes
732
+ buf = ctypes.create_unicode_buffer(1024)
733
+ n = ctypes.windll.kernel32.GetShortPathNameW(str(p), buf, len(buf))
734
+ return buf.value if 0 < n < len(buf) else p
735
+ except (AttributeError, OSError, ValueError):
736
+ return p
737
+
738
+
739
+ def default_command(executable=None, nt=None):
740
+ """command= for this PC: the Python running ecadlib now (a venv's python.exe too) with -m ecadlib, the path quoted
741
+ for cmd.exe ("C:\\venv\\Scripts\\python.exe" -m ecadlib). '' when not on Windows (the menu runs on the Altium PC,
742
+ where this Python's path means nothing), or when the path cannot be written in plain ASCII or holds a quote."""
743
+ nt = os.name == "nt" if nt is None else nt
744
+ exe = str(sys.executable if executable is None else executable or "")
745
+ if not nt or not exe:
746
+ return ""
747
+ if not exe.isascii(): # the .ini is read in the ANSI code page: use the 8.3 name
748
+ exe = _short_path(exe)
749
+ if not exe.isascii() or '"' in exe:
750
+ return ""
751
+ return f'"{exe}" -m ecadlib'
752
+
753
+
754
+ def _fill_command(ini, command):
755
+ """Set command= in an existing ecadlib_menu.ini when it is empty (nothing else changes). True when it did."""
756
+ data = ini.read_bytes()
757
+ new = re.sub(rb"(?m)^command=[ \t]*(?=\r?$)", lambda m: b"command=" + command.encode("utf-8"), data, count=1)
758
+ if new == data:
759
+ return False
760
+ ini.write_bytes(new)
761
+ return True
762
+
763
+
764
+ def build(out, workspace=None, library=None, name="ecadlib", command=None, win_dir=None, port=8765, hidden=True,
765
+ timeout_s=1800, offline=False):
766
+ """Write the script project into `out`. Returns a report dict (also altium_menu.json). command None or '': on
767
+ Windows, the Python running this (default_command()); elsewhere empty (the runner then searches PATH / py / python)."""
768
+ out = Path(out)
769
+ command = command or default_command()
770
+ out.mkdir(parents=True, exist_ok=True)
771
+ if win_dir is None and os.name == "nt":
772
+ win_dir = str(out.resolve())
773
+ if win_dir:
774
+ win_dir = check_win_dir(win_dir)
775
+ if name and not re.match(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,59}$", name):
776
+ raise ValueError(f"--name {name!r}: letters, digits, _ . - only")
777
+ from . import altium_forms as AF
778
+ files = {PROJECT: prjscr_text(UNIT, AF.DIALOG_UNIT), UNIT: pas_text(),
779
+ AF.DIALOG_UNIT: AF.dialog_pas_text(), AF.DIALOG_FORM: AF.dialog_dfm_text(),
780
+ RUN_CMD: run_cmd_text(), RUN_VBS: run_vbs_text(), README: readme_text(win_dir, bool(win_dir))}
781
+ written, kept = [], []
782
+ for fname, text in files.items():
783
+ data = text.encode("utf-8")
784
+ p = out / fname
785
+ if not p.is_file() or p.read_bytes() != data:
786
+ p.write_bytes(data)
787
+ written.append(fname)
788
+ ini = out / INI
789
+ filled = False
790
+ if ini.is_file():
791
+ kept.append(INI)
792
+ filled = bool(command) and _fill_command(ini, command) # an empty command= (older builds) gets this PC's
793
+ else:
794
+ ini.write_bytes(ini_text(workspace or "workspace", library or "library", name, command, hidden, timeout_s,
795
+ port, offline).encode("utf-8"))
796
+ written.append(INI)
797
+ if win_dir:
798
+ data = dxpprf_bytes(win_dir)
799
+ if not (out / PREFS).is_file() or (out / PREFS).read_bytes() != data:
800
+ (out / PREFS).write_bytes(data)
801
+ written.append(PREFS)
802
+ problems = project_problems(files)
803
+ rep = {"folder": str(out), "project": PROJECT, "procedures": [p for p, _, _ in PROCS], "written": written,
804
+ "kept": kept, "win_dir": win_dir, "menu_file": PREFS if win_dir else None, "problems": problems,
805
+ "command": load_command(ini), "command_filled": filled}
806
+ (out / "altium_menu.json").write_text(json.dumps(rep, indent=2) + "\n", encoding="utf-8")
807
+ return rep
808
+
809
+
810
+ def prjscr_text(*units):
811
+ """Script project as Altium saves one (Altium-Designer-addons/scripts-libraries QuickSilk.PrjScr, OpenLib.PrjScr for
812
+ several units). A form unit's .dfm is found next to its .pas."""
813
+ lines = ["[Design]", "Version=1.0", ""]
814
+ for i, u in enumerate(units, 1):
815
+ lines += [f"[Document{i}]", f"DocumentPath={u}", ""]
816
+ return "\r\n".join(lines)
817
+
818
+
819
+ # ------------------------------------------------------------------ a small Pascal check (also used by the tests)
820
+ PAS_KEYWORDS = {"begin", "end", "var", "const", "function", "procedure", "if", "then", "else", "while", "do", "for",
821
+ "to", "downto", "repeat", "until", "not", "and", "or", "div", "mod", "result", "exit", "break", "true",
822
+ "false", "nil", "try", "finally", "except", "string", "integer", "boolean", "case", "of"}
823
+ PAS_TYPES = {"tinifile", "tstringlist", "iproject"}
824
+ GLOBALS = " globals" # pas_routines' key for a unit's Var block (no routine has a space)
825
+
826
+
827
+ def pas_tokens(text):
828
+ """[(kind, value, line)] for DelphiScript: comments { } (* *) //, strings '..'' ..', #13 char codes, words,
829
+ numbers, symbols. Raises ValueError on an unterminated comment or string."""
830
+ toks, i, line, n = [], 0, 1, len(text)
831
+ while i < n:
832
+ c = text[i]
833
+ if c == "\n":
834
+ line += 1
835
+ i += 1
836
+ elif c in " \t\r":
837
+ i += 1
838
+ elif c == "{":
839
+ j = text.find("}", i)
840
+ if j < 0:
841
+ raise ValueError(f"line {line}: unterminated {{ comment")
842
+ line += text.count("\n", i, j)
843
+ i = j + 1
844
+ elif text.startswith("(*", i):
845
+ j = text.find("*)", i)
846
+ if j < 0:
847
+ raise ValueError(f"line {line}: unterminated (* comment")
848
+ line += text.count("\n", i, j)
849
+ i = j + 2
850
+ elif text.startswith("//", i):
851
+ j = text.find("\n", i)
852
+ i = n if j < 0 else j
853
+ elif c == "'":
854
+ j, s = i + 1, ""
855
+ while True:
856
+ k = text.find("'", j)
857
+ if k < 0 or "\n" in text[j:k]:
858
+ raise ValueError(f"line {line}: unterminated string")
859
+ s += text[j:k]
860
+ if text.startswith("''", k):
861
+ s += "'"
862
+ j = k + 2
863
+ continue
864
+ i = k + 1
865
+ break
866
+ toks.append(("str", s, line))
867
+ elif c == "#":
868
+ m = re.match(r"#\d+", text[i:])
869
+ if not m:
870
+ raise ValueError(f"line {line}: bad character code")
871
+ toks.append(("str", chr(int(m.group(0)[1:])), line))
872
+ i += len(m.group(0))
873
+ elif c.isalpha() or c == "_":
874
+ m = re.match(r"[A-Za-z_][A-Za-z0-9_]*", text[i:])
875
+ toks.append(("word", m.group(0), line))
876
+ i += len(m.group(0))
877
+ elif c == "$" and re.match(r"\$[0-9A-Fa-f]+", text[i:]): # $00D9772F: a hex number (TColor)
878
+ m = re.match(r"\$[0-9A-Fa-f]+", text[i:])
879
+ toks.append(("num", m.group(0), line))
880
+ i += len(m.group(0))
881
+ elif c.isdigit():
882
+ m = re.match(r"\d+(\.\d+)?", text[i:])
883
+ toks.append(("num", m.group(0), line))
884
+ i += len(m.group(0))
885
+ else:
886
+ two = text[i:i + 2]
887
+ if two in (":=", "<>", "<=", ">="):
888
+ toks.append(("sym", two, line))
889
+ i += 2
890
+ else:
891
+ toks.append(("sym", c, line))
892
+ i += 1
893
+ return toks
894
+
895
+
896
+ def pas_routines(toks):
897
+ """{name: {"params", "vars", "body": [tokens], "kind"}} for each top-level Function / Procedure."""
898
+ out, i = {}, 0
899
+ low = lambda t: t[1].lower() if t[0] == "word" else None
900
+ while i < len(toks):
901
+ if low(toks[i]) == "var" and not out: # unit globals, before the first routine
902
+ j, names = i + 1, set()
903
+ while low(toks[j]) not in ("function", "procedure"):
904
+ while toks[j][1] != ":":
905
+ if toks[j][0] == "word":
906
+ names.add(toks[j][1].lower())
907
+ j += 1
908
+ while toks[j][1] != ";":
909
+ j += 1
910
+ j += 1
911
+ out[GLOBALS] = {"kind": "var", "params": set(), "vars": names, "body": []}
912
+ i = j
913
+ elif low(toks[i]) in ("function", "procedure"):
914
+ kind, name = low(toks[i]), toks[i + 1][1]
915
+ j, params = i + 2, set()
916
+ if toks[j][1] == "." and toks[j + 1][0] == "word": # Procedure TForm1.Button1Click(Sender: TObject)
917
+ name, j = f"{name}.{toks[j + 1][1]}", j + 2
918
+ if toks[j][1] == "(":
919
+ k = j
920
+ while toks[k][1] != ")":
921
+ if toks[k][0] == "word" and toks[k + 1][1] in (":", ",") and toks[k - 1][1] in ("(", ";", ","):
922
+ params.add(toks[k][1].lower())
923
+ k += 1
924
+ j = k + 1
925
+ while toks[j][1] != ";":
926
+ j += 1
927
+ j += 1
928
+ vars_ = set()
929
+ if low(toks[j]) == "var":
930
+ j += 1
931
+ while low(toks[j]) != "begin":
932
+ names = []
933
+ while toks[j][1] != ":":
934
+ if toks[j][0] == "word":
935
+ names.append(toks[j][1].lower())
936
+ j += 1
937
+ vars_.update(names)
938
+ while toks[j][1] != ";":
939
+ j += 1
940
+ j += 1
941
+ if low(toks[j]) != "begin":
942
+ raise ValueError(f"line {toks[j][2]}: {name}: expected Begin")
943
+ depth, k = 0, j
944
+ while True:
945
+ w = low(toks[k])
946
+ if w in ("begin", "try", "case"):
947
+ depth += 1
948
+ elif w == "end":
949
+ depth -= 1
950
+ if depth == 0:
951
+ break
952
+ k += 1
953
+ if k >= len(toks):
954
+ raise ValueError(f"{name}: Begin without End")
955
+ if toks[k + 1][1] != ";":
956
+ raise ValueError(f"line {toks[k][2]}: {name}: End without ;")
957
+ out[name] = {"kind": kind, "params": params, "vars": vars_, "body": toks[j:k + 1]}
958
+ i = k + 2
959
+ else:
960
+ raise ValueError(f"line {toks[i][2]}: {toks[i][1]!r} outside a Function / Procedure")
961
+ return out
962
+
963
+
964
+ def pas_problems(text, api=None, known=(), procs=None):
965
+ """What a reader of the .pas would object to: unbalanced Begin/End, an identifier that is neither declared here
966
+ (routine, unit global, parameter, local variable), nor in `known` (the other unit's routines and globals, the
967
+ form's components), nor in the documented allow-list (ALTIUM_API), a Function without a Result, a procedure the
968
+ menu needs (procs, default PROCS) that is missing or takes parameters. [] = fine."""
969
+ api = {k.lower() for k in (api or ALTIUM_API)}
970
+ try:
971
+ routines = pas_routines(pas_tokens(text))
972
+ except (ValueError, IndexError) as e:
973
+ return [f"does not parse: {e}"]
974
+ probs = []
975
+ glob = routines.pop(GLOBALS, {"vars": set()})["vars"]
976
+ known = {n.lower() for n in routines} | {n.split(".")[-1].lower() for n in routines} | glob | {k.lower() for k in known}
977
+ order = {n.lower(): i for i, n in enumerate(routines) if "." not in n} # plain routines, in file order
978
+ for here, (name, r) in enumerate(routines.items()):
979
+ local = r["params"] | r["vars"]
980
+ for idx, (kind, val, line) in enumerate(r["body"]):
981
+ if kind != "word":
982
+ continue
983
+ w = val.lower()
984
+ if order.get(w, -1) > here and w not in local: # DelphiScript has no forward declarations (AD 26.10:
985
+ probs.append(f"line {line}: {name}: {val} is used before it is declared") # "Undeclared identifier")
986
+ if w in PAS_KEYWORDS or w in PAS_TYPES or w in known or w in local or w in api:
987
+ continue
988
+ probs.append(f"line {line}: {name}: {val} is not declared and not in the API allow-list")
989
+ if r["kind"] == "function" and not any(t[0] == "word" and t[1].lower() == "result" for t in r["body"]):
990
+ probs.append(f"{name}: a Function that never sets Result")
991
+ for proc, _, _ in (PROCS if procs is None else procs):
992
+ if proc not in routines:
993
+ probs.append(f"{proc}: missing")
994
+ elif routines[proc]["params"] or routines[proc]["kind"] != "procedure":
995
+ probs.append(f"{proc}: must be a Procedure without parameters (Altium's menus cannot pass any)")
996
+ return probs
997
+
998
+
999
+ def unit_names(text):
1000
+ """Routines and unit globals a .pas declares (what another unit of the script project may call)."""
1001
+ try:
1002
+ r = pas_routines(pas_tokens(text))
1003
+ except (ValueError, IndexError):
1004
+ return set()
1005
+ return {n for n in r if n != GLOBALS and "." not in n} | set(r.get(GLOBALS, {}).get("vars", ()))
1006
+
1007
+
1008
+ def project_problems(files):
1009
+ """pas_problems of every unit of the script project, each knowing the other units' routines and globals; a form
1010
+ unit also knows its .dfm's components and the form API (altium_forms.FORM_API). Every handler a .dfm
1011
+ names (OnClick = X) must be a method of its form in the unit. Problems are prefixed with the file name."""
1012
+ from . import altium_forms as AF
1013
+ units = {UNIT: None, AF.DIALOG_UNIT: AF.DIALOG_FORM}
1014
+ names = {u: unit_names(files[u]) for u in units}
1015
+ api = {**ALTIUM_API, **AF.FORM_API}
1016
+ probs = []
1017
+ for u, form_file in units.items():
1018
+ others = set().union(*(n for o, n in names.items() if o != u))
1019
+ if form_file is None:
1020
+ probs += [f"{u}: {p}" for p in pas_problems(files[u], known=others)]
1021
+ continue
1022
+ comps = AF.dfm_names(files[form_file])
1023
+ probs += [f"{u}: {p}" for p in pas_problems(files[u], api, known=others | set(comps), procs=[])]
1024
+ form = comps[0] if comps else ""
1025
+ methods = {n.split(".")[1] for n in pas_routines(pas_tokens(files[u])) if n.startswith(f"T{form}.")}
1026
+ handlers = set(re.findall(r"(?m)^\s*On\w+ = (\w+)", files[form_file]))
1027
+ probs += [f"{form_file}: {h} is named in the form but {u} has no T{form}.{h}" for h in sorted(handlers - methods)]
1028
+ return probs
1029
+
1030
+
1031
+ # ------------------------------------------------------------------ files the script and ecadlib exchange
1032
+ def read_ini(path):
1033
+ """configparser of an INI the script wrote with TIniFile (Windows' profile API: UTF-16 with a BOM, else the ANSI
1034
+ code page) or by hand (UTF-8)."""
1035
+ data = Path(path).read_bytes()
1036
+ if data.startswith((b"\xff\xfe", b"\xfe\xff")):
1037
+ text = data.decode("utf-16")
1038
+ else:
1039
+ for enc in ("utf-8-sig", "mbcs", "cp1252", "latin-1"):
1040
+ try:
1041
+ text = data.decode(enc)
1042
+ break
1043
+ except (UnicodeDecodeError, LookupError):
1044
+ continue
1045
+ cp = configparser.ConfigParser(interpolation=None, strict=False)
1046
+ cp.optionxform = str
1047
+ cp.read_string(text)
1048
+ return cp
1049
+
1050
+
1051
+ def _one_line(v):
1052
+ return re.sub(r"\s*[\r\n]+\s*", " ", str(v if v is not None else "")).strip()
1053
+
1054
+
1055
+ def result_text(res, section="result"):
1056
+ first = [k for k in ("status", "message", "fix") if section == "result" or k in res]
1057
+ keys = first + [k for k in res if k not in first]
1058
+ return f"[{section}]\r\n" + "".join(f"{k}={_one_line(res.get(k, ''))}\r\n" for k in keys)
1059
+
1060
+
1061
+ def write_result(path, res, section="result"):
1062
+ """result.ini as UTF-16 LE with a BOM (what TIniFile / GetPrivateProfileStringW reads whatever the code page),
1063
+ written to a temp file and moved into place, so the script never reads half a file."""
1064
+ path = Path(path)
1065
+ tmp = path.with_name(path.name + f".{os.getpid()}.tmp")
1066
+ tmp.write_bytes(b"\xff\xfe" + result_text(res, section).encode("utf-16-le"))
1067
+ os.replace(tmp, path)
1068
+
1069
+
1070
+ class Progress:
1071
+ """<job>/progress.ini while the job runs, for the dialog's live view ([progress] done, total, text, rows, row1..).
1072
+ Rows are "label|value|ok" lines (ok = 1 shows the value in green). Written like result.ini (UTF-16, moved into
1073
+ place); a write the script's read collides with on Windows is skipped, the next one carries the same state."""
1074
+
1075
+ def __init__(self, job=None, every_s=0.25, clock=time.monotonic):
1076
+ self.path = Path(job) / "progress.ini" if job else None
1077
+ self.done, self.total, self.text, self.rows = 0, 0, "", []
1078
+ self.every_s, self.clock, self.last = every_s, clock, None
1079
+
1080
+ def step(self, done, total, text):
1081
+ self.done, self.total, self.text = done, total, step_text(text)
1082
+ self.flush(force=bool(total) and done >= total) # the last step always shows
1083
+
1084
+ def row(self, label, value="", ok=False):
1085
+ self.rows.append(row_text(label, value, ok))
1086
+ self.flush()
1087
+
1088
+ def __call__(self, done, total, text): # Workspace.triage / librarian_run progress(done, total, label)
1089
+ self.step(done, total, text)
1090
+
1091
+ def flush(self, force=True):
1092
+ if self.path is None or (not force and self.last is not None and self.clock() - self.last < self.every_s):
1093
+ return
1094
+ self.last = self.clock()
1095
+ res = {"done": self.done, "total": self.total, "text": self.text, "rows": len(self.rows)}
1096
+ res.update({f"row{i}": r for i, r in enumerate(self.rows, 1)})
1097
+ try:
1098
+ write_result(self.path, res, section="progress")
1099
+ except OSError:
1100
+ pass
1101
+
1102
+
1103
+ def clip(text, n):
1104
+ """One line of at most n characters, cut at a word with "..." when longer (the dialog's columns do not wrap)."""
1105
+ t = _one_line(text)
1106
+ if len(t) <= n:
1107
+ return t
1108
+ cut = t[:n - 3]
1109
+ if t[n - 3] != " " and " " in cut: # a word cut in two: drop it
1110
+ cut = cut.rsplit(" ", 1)[0]
1111
+ return cut.rstrip(" ,;:(") + "..."
1112
+
1113
+
1114
+ def row_text(label, value="", ok=False):
1115
+ """One dialog row: "label|value|1" (the script splits on |, so neither part may hold one). The label column fits
1116
+ about 26 characters, the value column about 28 (altium_forms.LABEL_W, WIDTH; Consolas is about 7 px a character, and
1117
+ a longer value would lose its start, the column being right-aligned)."""
1118
+ clean = lambda v, n: clip(str(v).replace("|", "/"), n)
1119
+ return f"{clean(label, 26)}|{clean(value, 28)}|{1 if ok else 0}"
1120
+
1121
+
1122
+ def step_text(msg):
1123
+ """A librarian log line -> what the dialog's status line says (it fits about 55 characters)."""
1124
+ t = _one_line(msg)
1125
+ m = re.match(r"^(done|open|skip)\s+(\S+)", t)
1126
+ if m:
1127
+ return clip(f"{m.group(2)}: {'checked' if m.group(1) != 'open' else 'still needs a person'}", 55)
1128
+ t = re.sub(r"\s*\(sources: [^)]*\)?", "", t)
1129
+ return clip(t, 55)
1130
+
1131
+
1132
+ def rows_result(rows, big=""):
1133
+ """{"big", "rows", "row1".."rowN"} for result.ini: what the dialog shows when the job is done."""
1134
+ out = {"big": big, "rows": len(rows)}
1135
+ out.update({f"row{i}": r for i, r in enumerate(rows, 1)})
1136
+ return out
1137
+
1138
+
1139
+ def load_command(ini_path):
1140
+ """command= of an ecadlib_menu.ini ('' when empty or missing)."""
1141
+ try:
1142
+ return (read_ini(ini_path)["ecadlib"].get("command") or "").strip()
1143
+ except (OSError, KeyError, configparser.Error):
1144
+ return ""
1145
+
1146
+
1147
+ def load_config(ini_path):
1148
+ """Settings from ecadlib_menu.ini with paths resolved against its folder."""
1149
+ ini_path = Path(ini_path)
1150
+ base = ini_path.parent
1151
+ sec = read_ini(ini_path)["ecadlib"] if ini_path.is_file() else {}
1152
+ get = lambda k, d="": (sec.get(k, d) or d).strip()
1153
+ def path(v, rel_to):
1154
+ if Path(v).is_absolute() or PureWindowsPath(v).is_absolute():
1155
+ return Path(v)
1156
+ return rel_to / Path(*PureWindowsPath(v).parts) # ..\\altium_library works on any OS
1157
+ ws = path(get("workspace", "workspace"), base)
1158
+ lib = path(get("library"), base) if get("library") else ws / "altium_library"
1159
+ try:
1160
+ port = int(get("port", "8765"))
1161
+ except ValueError:
1162
+ raise ValueError(f"{ini_path}: port= must be a number")
1163
+ return {"workspace": ws, "library": lib, "name": get("name", "ecadlib") or "ecadlib", "port": port,
1164
+ "offline": get("offline", "0") == "1"}
1165
+
1166
+
1167
+ # ------------------------------------------------------------------ the actions
1168
+ MPN_RE = re.compile(r"^[A-Za-z0-9][-A-Za-z0-9_.+/#,() ]{0,79}$")
1169
+
1170
+
1171
+ def add_to_parts(ws_root, mpn, manufacturer="", **hints):
1172
+ """Add (or update) one line in <workspace>/uploads/altium-menu/parts.csv. Returns the file."""
1173
+ mpn = (mpn or "").strip()
1174
+ if not MPN_RE.match(mpn):
1175
+ raise ValueError(f"not a manufacturer part number: {mpn!r} (letters, digits and - _ . + / # , ( ) only)")
1176
+ bom = Path(ws_root) / MENU_BOM
1177
+ bom.parent.mkdir(parents=True, exist_ok=True)
1178
+ rows = list(csv.DictReader(bom.open(encoding="utf-8", newline=""))) if bom.is_file() else []
1179
+ new = {"Mfr. Part #": mpn, "Manufacturer": (manufacturer or "").strip(), "Value": hints.get("value") or "",
1180
+ "Footprint": hints.get("footprint") or "", "LCSC Part Number": hints.get("lcsc") or "",
1181
+ "Description": hints.get("description") or "", "Datasheet": hints.get("datasheet") or "",
1182
+ "Designator": hints.get("designator") or ""}
1183
+ row = next((r for r in rows if r.get("Mfr. Part #", "").strip().upper() == mpn.upper()), None)
1184
+ if row is None:
1185
+ row = {"Qty": "1"}
1186
+ rows.append(row)
1187
+ row.update({k: v for k, v in new.items() if v or k not in row})
1188
+ for i, r in enumerate(rows, 1):
1189
+ if not r.get("Designator"):
1190
+ r["Designator"] = f"U{i}"
1191
+ if not r.get("Value"):
1192
+ r["Value"] = r.get("Mfr. Part #", "")
1193
+ text = io.StringIO()
1194
+ w = csv.DictWriter(text, HEADER, extrasaction="ignore", lineterminator="\n")
1195
+ w.writeheader()
1196
+ for r in rows:
1197
+ w.writerow({k: r.get(k, "") for k in HEADER})
1198
+ if not bom.is_file() or bom.read_text(encoding="utf-8") != text.getvalue():
1199
+ bom.write_text(text.getvalue(), encoding="utf-8", newline="")
1200
+ return bom
1201
+
1202
+
1203
+ def _mpn_of(item):
1204
+ par = {p["name"]: str(p.get("value", "")) for p in item.get("parameters", [])}
1205
+ return par.get("Manufacturer Part Number", "")
1206
+
1207
+
1208
+ def _item_for(items, mpn):
1209
+ m = mpn.strip().upper()
1210
+ return (next((i for i in items if _mpn_of(i).strip().upper() == m), None)
1211
+ or next((i for i in items if str(i.get("name", "")).strip().upper() == m), None))
1212
+
1213
+
1214
+ def _a365_url(ws):
1215
+ return str((ws.rules().get("altium365") or {}).get("workspace") or "").strip()
1216
+
1217
+
1218
+ def publish(ws, bom_rel, library, name="ecadlib", tool="altium"):
1219
+ """Ready parts of the BOM's plan -> the library folder (altium-pkg / dblib-sync) or Altium 365 (push); with
1220
+ tool="kicad", the KiCad library folder (kicad-export). Returns {"target", "install": [DbLib paths], "problems", "note"}."""
1221
+ if tool == "kicad":
1222
+ return publish_kicad(ws, bom_rel, library, name)
1223
+ from . import altium_pkg as AP
1224
+ url = _a365_url(ws)
1225
+ if url:
1226
+ try:
1227
+ res = ws.altium365_push(bom_rel, url, dry_run=False)
1228
+ except ValueError as e:
1229
+ return {"target": "a365", "install": [], "problems": [str(e)], "workspace": url,
1230
+ "fix": "set NEXAR_TOKEN in your environment, then restart Altium" if "NEXAR_TOKEN" in str(e) else ""}
1231
+ acts = [r.get("action") for r in res["results"]]
1232
+ return {"target": "a365", "install": [], "problems": [f"{r['name']}: {r.get('detail')}" for r in res["results"]
1233
+ if r.get("action") == "error"],
1234
+ "workspace": url, "released": sum(a in ("release", "released", "create", "created", "update", "updated")
1235
+ for a in acts), "report": str(ws.root / res["report"])}
1236
+ bom_path, plan_f, items = ws._plan(bom_rel)
1237
+ if not any(i.get("status") == "ready" for i in items) and not any(Path(library).glob("*.DbLib")):
1238
+ return {"target": "folder", "install": [], "problems": [], "library": str(library)}
1239
+ try:
1240
+ rep = AP.build(plan_f, library, name, rules=ws.rules_in_use(), log=lambda *a: None)
1241
+ except ValueError as e:
1242
+ return {"target": "folder", "install": [], "problems": [str(e)], "library": str(library)}
1243
+ return {"target": "folder", "install": [str((Path(library) / d).resolve()) for d in rep["dblibs"]],
1244
+ "problems": rep["problems"], "library": str(library)}
1245
+
1246
+
1247
+ def publish_kicad(ws, bom_rel, library, name="ecadlib"):
1248
+ """The plan's parts -> <library>/<name>.kicad_sym, .pretty, .3dshapes (kicad_export). One plan makes the whole
1249
+ library, so the KiCad menu keeps every part in one list (MENU_BOM). "footprints" maps part -> footprint name."""
1250
+ from . import kicad_export as KX
1251
+ from .httpcache import CachedHTTP
1252
+ _, plan_f, _ = ws._plan(bom_rel)
1253
+ rep = KX.export(plan_f, library, name, http=CachedHTTP(ws.root / "http_cache", offline=ws.offline, timeout=60),
1254
+ rules=ws.rules_in_use(), log=lambda *a, **k: None)
1255
+ return {"target": "kicad", "install": [], "problems": rep["errors"], "library": str(library),
1256
+ "footprints": {p["part"]: p.get("footprint") or "" for p in rep["parts"]}}
1257
+
1258
+
1259
+ def _where(cfg, pub):
1260
+ if pub.get("target") == "a365":
1261
+ return f"released to Altium 365 ({pub['workspace']}): refresh the Components panel to see it"
1262
+ if pub.get("target") == "kicad":
1263
+ return f"in the {cfg['name']} KiCad library ({pub.get('library')})"
1264
+ return f"in the {cfg['name']} database library ({pub.get('library')})"
1265
+
1266
+
1267
+ def _queue_hint(cfg):
1268
+ return "the Open review queue button in the ecadlib window" if cfg.get("tool") == "kicad" else "ecadlib > Open review queue"
1269
+
1270
+
1271
+ def _workspace(cfg):
1272
+ from .app import Workspace
1273
+ Path(cfg["workspace"]).mkdir(parents=True, exist_ok=True)
1274
+ return Workspace(cfg["workspace"], offline=cfg.get("offline", False))
1275
+
1276
+
1277
+ def part_rows(it, limit=7):
1278
+ """What the dialog lists for one built part: where ecadlib looked, what it took, and the release check."""
1279
+ from . import messages as M
1280
+ rows, src = [], it.get("source") or {}
1281
+ for t in (it.get("librarian") or {}).get("tried", [])[:4]:
1282
+ kind, _, rest = str(t).partition(":")
1283
+ if rest and kind.strip() in M.SOURCES:
1284
+ rows.append(row_text(M.SOURCES[kind.strip()], rest.strip()))
1285
+ for k in ("symbol", "footprint"):
1286
+ rows.append(row_text(k, M.source_word(src.get(k)) if src.get(k) else "none yet", bool(src.get(k))))
1287
+ st = it.get("status", "")
1288
+ rows.append(row_text("release check", M.status_word(st), st in ("ready", "duplicate")))
1289
+ return rows[-limit:] if len(rows) > limit else rows
1290
+
1291
+
1292
+ def add_part(cfg, mpn, manufacturer="", progress=None, **hints):
1293
+ """Build one part from its MPN and publish it. Returns the result dict."""
1294
+ progress = progress or Progress()
1295
+ ws = _workspace(cfg)
1296
+ bom = add_to_parts(ws.root, mpn, manufacturer, **hints)
1297
+ rel = ws.rel(bom)
1298
+ progress.step(0, 0, f"looking up {mpn}")
1299
+ ws.librarian_run(rel, progress=lambda d, t, text: progress.step(0, 0, text))
1300
+ _, _, items = ws._plan(rel)
1301
+ it = _item_for(items, mpn)
1302
+ if it is None:
1303
+ return {"status": "error", "message": f"ecadlib could not add {mpn} to the library.",
1304
+ "fix": f"Check the part number. What was tried is in {ws.root / ws._librarian_dir(bom).relative_to(ws.root) / 'review.html'}"}
1305
+ if it["status"] == "ready":
1306
+ progress.step(0, 0, "putting it into the library")
1307
+ pub = (publish(ws, rel, cfg["library"], cfg["name"], cfg.get("tool", "altium")) if it["status"] == "ready"
1308
+ else {"install": [], "problems": []})
1309
+ st, nm = it["status"], it["name"]
1310
+ res = {"status": st, "part": nm, "mpn": mpn, "install": ";".join(pub.get("install") or []),
1311
+ "refresh": pub.get("target", ""), "review": str(ws._librarian_dir(bom) / "review.md")}
1312
+ from . import messages as M
1313
+ whys = M.explain_all(it.get("hold_reasons") or [])
1314
+ if st == "ready":
1315
+ res["message"] = f"{mpn} is ready as {nm}: {_where(cfg, pub)}."
1316
+ if pub.get("footprints", {}).get(nm):
1317
+ res["footprint"] = pub["footprints"][nm]
1318
+ if pub.get("problems"):
1319
+ res.update(status="error", message=f"{mpn} was built ({nm}) but not published: {pub['problems'][0]}",
1320
+ fix=pub.get("fix") or "run `ecadlib altium-pkg` or `ecadlib altium365 push` by hand to see more")
1321
+ elif st == "awaiting_approval":
1322
+ res.update(message=f"{mpn} was built as {nm} and is waiting for sign-off before it goes into the library.",
1323
+ fix=f"Approve it in the review queue ({_queue_hint(cfg)}), then add it again.")
1324
+ elif st == "duplicate":
1325
+ res.update(message=f"{mpn} is already in the library as {it.get('duplicate_of') or nm}.")
1326
+ else:
1327
+ res.update(status="held", message=f"{mpn} is held ({nm}): " + (" ".join(w["text"] for w in whys[:3]) or "see the review."),
1328
+ fix=(whys[0]["fix"] + " " if whys else "") + f"Open the review queue ({_queue_hint(cfg)}) "
1329
+ "to pick a symbol or footprint, or release it anyway.")
1330
+ res.update(rows_result(part_rows(it), {"ready": "Ready", "duplicate": "Already in your library",
1331
+ "awaiting_approval": "Waiting for sign-off"}.get(st, "Held")))
1332
+ if res["status"] == "error":
1333
+ res["big"] = "Not published"
1334
+ return res
1335
+
1336
+
1337
+ def _library_mpns(cfg, ws):
1338
+ """MPNs already in the library: the DbLib parts table (any 'part number' column) and every ready / pulled plan item."""
1339
+ have = set()
1340
+ for f in sorted(Path(cfg["library"]).glob("*.csv")):
1341
+ try:
1342
+ for row in csv.DictReader(f.open(encoding="utf-8", newline="")):
1343
+ have |= {str(v).strip().upper() for k, v in row.items() if k and "part number" in k.lower() and v}
1344
+ except (OSError, UnicodeDecodeError, csv.Error):
1345
+ continue
1346
+ for plan in sorted((ws.root / "librarian").glob("*/plan.json")) + [ws.root / "altium365" / "workspace_plan.json"]:
1347
+ try:
1348
+ items = json.loads(plan.read_text(encoding="utf-8"))
1349
+ except (OSError, ValueError):
1350
+ continue
1351
+ have |= {_mpn_of(i).strip().upper() for i in items if i.get("status") in ("ready", "existing") and _mpn_of(i)}
1352
+ return have
1353
+
1354
+
1355
+ def _bom_of(ws, project):
1356
+ """(workspace-relative BOM csv, project summary or None) for an Altium project / SchDoc, or a BOM file."""
1357
+ p = Path(project)
1358
+ if not p.is_file():
1359
+ raise ValueError(f"{project}: no such file (save the project first)")
1360
+ if p.suffix.lower() in (".prjpcb", ".schdoc"):
1361
+ pb = ws.project_bom(str(p.resolve()))
1362
+ return pb["csv"], pb
1363
+ return str(p.resolve()), None
1364
+
1365
+
1366
+ def report_html(title, rows, counts, md_link):
1367
+ e = html.escape
1368
+ head = ["#", "Designators", "Qty", "MPN", "Manufacturer", "In your library", "Lookup", "Notes"]
1369
+ body = "".join("<tr>" + "".join(f"<td>{e(str(c))}</td>" for c in (
1370
+ r["line"], r["designator"], r["qty"], r["mpn"], r["manufacturer"], "yes" if r["in_library"] else "NO",
1371
+ r["status"], r["notes"])) + "</tr>\n" for r in rows)
1372
+ return f"""<!doctype html><html><head><meta charset="utf-8"><title>{e(title)}</title>
1373
+ <style>body{{font:14px system-ui,sans-serif;margin:24px;color:#222}}table{{border-collapse:collapse}}
1374
+ td,th{{border:1px solid #ccc;padding:3px 8px;text-align:left;vertical-align:top}}th{{background:#f2f2f2}}
1375
+ .n{{color:#b00;font-weight:600}}</style></head><body><h1>{e(title)}</h1>
1376
+ <p>{counts['lines']} BOM lines: <b>{counts['in_library']}</b> in your library, <span class="n">{counts['missing']}</span>
1377
+ not yet, {counts['dnp']} not fitted. Full lookup report: <a href="{e(md_link)}">{e(Path(md_link).name)}</a>.</p>
1378
+ <table><tr>{''.join(f'<th>{h}</th>' for h in head)}</tr>
1379
+ {body}</table><p><small>written by ecadlib {__version__} (ecadlib altium-menu check-bom)</small></p></body></html>
1380
+ """
1381
+
1382
+
1383
+ def check_bom(cfg, project, opener=None, progress=None):
1384
+ """BOM of an Altium project -> triage + which lines the library already has -> HTML report (opened unless
1385
+ opener is False: the dialog shows the counts and offers the report)."""
1386
+ progress = progress or Progress()
1387
+ ws = _workspace(cfg)
1388
+ progress.step(0, 0, "reading the project's BOM")
1389
+ bom, pb = _bom_of(ws, project)
1390
+ tri = ws.triage(bom, progress=lambda d, t, text: progress.step(d, t, f"line {min(d + 1, t)} of {t}: {text}"
1391
+ if d < t else text))
1392
+ have = _library_mpns(cfg, ws)
1393
+ rows, counts = [], {"lines": 0, "in_library": 0, "missing": 0, "dnp": 0}
1394
+ kinds = {"source": 0, "footprint": 0, "unverified": 0}
1395
+ for r in tri["rows"]:
1396
+ dnp = str(r.get("status", "")).startswith("dnp")
1397
+ inlib = bool(r.get("mpn")) and r["mpn"].strip().upper() in have
1398
+ rows.append({**r, "in_library": inlib})
1399
+ counts["lines"] += 1
1400
+ counts["dnp" if dnp else "in_library" if inlib else "missing"] += 1
1401
+ if not dnp and not inlib:
1402
+ st = str(r.get("status", ""))
1403
+ kinds["footprint" if st.startswith("needs_footprint") else "unverified" if st.startswith("unverified")
1404
+ else "source"] += 1
1405
+ stem = Path(tri["reports"][0]).with_suffix("").name
1406
+ out = ws.root / "reports" / f"{stem}_altium.html"
1407
+ name = pb["project"] if pb else Path(project).name
1408
+ out.write_text(report_html(f"BOM check: {name}", rows, counts, (ws.root / tri["reports"][0]).as_uri()),
1409
+ encoding="utf-8")
1410
+ opened = _open(out, opener)
1411
+ res = {"status": "ok", "message": f"{name}: {counts['lines']} BOM lines, {counts['in_library']} in your library, "
1412
+ f"{counts['missing']} not yet" + (f", {counts['dnp']} not fitted" if counts["dnp"] else "")
1413
+ + (". The report is open in your browser." if opened else f". Report: {out}"),
1414
+ "report": str(out), "bom": str(ws.root / bom) if not Path(bom).is_absolute() else bom,
1415
+ "lines": counts["lines"], "missing": counts["missing"], "offer_fill": "1" if counts["missing"] else "0"}
1416
+ if pb and pb.get("warnings"):
1417
+ res["warnings"] = "; ".join(pb["warnings"])[:400]
1418
+ if counts["missing"]:
1419
+ res["offer"] = (f"Fill the library now? ecadlib looks up the {counts['missing']} missing part(s), builds what it "
1420
+ "can and puts the ready ones into the library (this can take a few minutes).")
1421
+ from . import messages as M
1422
+ shown = [row_text("already in your library", counts["in_library"], True),
1423
+ row_text("a source can supply", kinds["source"]) if kinds["source"] else None,
1424
+ row_text("needs a footprint", kinds["footprint"]) if kinds["footprint"] else None,
1425
+ row_text("could not check (offline)", kinds["unverified"]) if kinds["unverified"] else None,
1426
+ row_text("not fitted", counts["dnp"]) if counts["dnp"] else None]
1427
+ res.update(rows_result([r for r in shown if r], M.plural(counts["lines"], "line")))
1428
+ return res
1429
+
1430
+
1431
+ def fill(cfg, project, progress=None):
1432
+ """Librarian on the project's BOM, then publish the ready parts."""
1433
+ progress = progress or Progress()
1434
+ ws = _workspace(cfg)
1435
+ bom, pb = _bom_of(ws, project)
1436
+ s = ws.librarian_run(bom, progress=progress)
1437
+ if s["ready"]:
1438
+ progress.step(progress.total, progress.total, "putting the ready parts into the library")
1439
+ pub = publish(ws, bom, cfg["library"], cfg["name"], cfg.get("tool", "altium")) if s["ready"] else {"install": [], "problems": []}
1440
+ where = ("released to Altium 365: refresh the Components panel" if pub.get("target") == "a365"
1441
+ else f"in the {cfg['name']} KiCad library" if pub.get("target") == "kicad"
1442
+ else f"in the {cfg['name']} database library")
1443
+ msg = f"{s['parts']} parts: {s['ready']} ready" + (f" ({where})" if s["ready"] else "")
1444
+ msg += "".join(f", {n} {label}" for n, label in ((s["held"], "held"), (s["awaiting_approval"], "waiting for sign-off"),
1445
+ (s["duplicates"], "duplicates")) if n)
1446
+ res = {"status": "ok" if not pub.get("problems") else "error", "message": msg + ".",
1447
+ "install": ";".join(pub.get("install") or []), "refresh": pub.get("target", ""),
1448
+ "ready": s["ready"], "held": s["held"], "review": str(ws._librarian_dir(ws._bom_path(bom)) / "review.md")}
1449
+ if pub.get("problems"):
1450
+ res["message"] += f" Not published: {pub['problems'][0]}"
1451
+ res["fix"] = pub.get("fix") or "run `ecadlib altium-pkg` / `ecadlib altium365 push` by hand to see more"
1452
+ elif s["held"] or s["awaiting_approval"]:
1453
+ res["fix"] = f"Open the review queue ({_queue_hint(cfg)}): it says why each part is held and what to pick."
1454
+ shown = [row_text("ready, in your library" if pub.get("target") != "a365" else "released to Altium 365", s["ready"], s["ready"] > 0)]
1455
+ shown += [row_text(label, n) for n, label in ((s["held"], "held for a person"), (s["awaiting_approval"], "waiting for sign-off"),
1456
+ (s["duplicates"], "already in the library")) if n]
1457
+ held = [r for r in s.get("review", []) if r.get("status") == "held"]
1458
+ shown += [row_text(r["part"], (r.get("why") or "").split(";")[0]) for r in held[:3]]
1459
+ if len(held) > 3:
1460
+ shown.append(row_text(f"and {len(held) - 3} more held", "in the review queue"))
1461
+ res.update(rows_result(shown, f"{s['ready']} of {s['parts']} ready"))
1462
+ res["offer_queue"] = "1" if s["held"] or s["awaiting_approval"] else "0"
1463
+ return res
1464
+
1465
+
1466
+ def _open(path_or_url, opener=None):
1467
+ """Open a file or URL in the default viewer/browser (opener False: don't). Returns True when it was handed over."""
1468
+ target = str(path_or_url)
1469
+ if opener is False:
1470
+ return False
1471
+ try:
1472
+ if opener is not None:
1473
+ opener(target)
1474
+ elif os.name == "nt" and not target.startswith("http"):
1475
+ os.startfile(target) # the default program for .html
1476
+ else:
1477
+ import webbrowser
1478
+ webbrowser.open(target if target.startswith("http") else Path(target).resolve().as_uri())
1479
+ return True
1480
+ except Exception:
1481
+ return False
1482
+
1483
+
1484
+ def open_file(cfg, path, opener=None):
1485
+ """Open a report the dialog offers (in the browser / its default program). Only files under the workspace or the
1486
+ library folder: a request.ini is plain text anyone can write, so it never names an arbitrary program to start."""
1487
+ p = Path(path)
1488
+ roots = [Path(cfg["workspace"]).resolve(), Path(cfg["library"]).resolve()]
1489
+ try:
1490
+ rp = p.resolve()
1491
+ except OSError:
1492
+ rp = p
1493
+ if not p.is_file() or not any(rp == r or r in rp.parents for r in roots):
1494
+ return {"status": "error", "message": f"{path}: not a report in the ecadlib workspace", "fix": "check the job's log.txt"}
1495
+ if rp.suffix.lower() not in (".html", ".htm", ".md", ".csv", ".txt"):
1496
+ return {"status": "error", "message": f"{rp.name}: ecadlib opens only reports (.html, .md, .csv, .txt)", "fix": ""}
1497
+ if not _open(rp, opener):
1498
+ return {"status": "error", "message": f"could not open {rp}", "fix": "open that file yourself"}
1499
+ return {"status": "ok", "message": f"opened {rp.name}"}
1500
+
1501
+
1502
+ def probe_ui(port, host="127.0.0.1", timeout=1.5):
1503
+ """What answers on the port: ("ecadlib", workspace) | ("ecadlib-team", None) | ("other", None) | (None, None)."""
1504
+ import urllib.request, urllib.error
1505
+ try:
1506
+ direct = urllib.request.build_opener(urllib.request.ProxyHandler({})) # never through a system proxy
1507
+ with direct.open(f"http://{host}:{port}/api/info", timeout=timeout) as r:
1508
+ data = json.loads(r.read().decode("utf-8"))
1509
+ return ("ecadlib", data.get("workspace")) if isinstance(data, dict) and "workspace" in data else ("other", None)
1510
+ except urllib.error.HTTPError as e:
1511
+ return ("ecadlib-team", None) if e.code in (401, 403) else ("other", None)
1512
+ except (OSError, ValueError):
1513
+ return (None, None)
1514
+
1515
+
1516
+ def start_ui(ws_root, port):
1517
+ """`ecadlib ui --workspace W --port P --no-browser`, detached, no window; output in altium_menu_ui.log."""
1518
+ from .userconfig import self_command # this Python, or the Windows app's bundled ecadlib.exe
1519
+ cmd = self_command() + ["ui", "--workspace", str(ws_root), "--port", str(port), "--no-browser"]
1520
+ log = open(Path(ws_root) / "altium_menu_ui.log", "ab")
1521
+ env = dict(os.environ) # this ecadlib, also when it runs from a checkout (not installed)
1522
+ env["PYTHONPATH"] = os.pathsep.join([str(Path(__file__).resolve().parent.parent)] +
1523
+ ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else []))
1524
+ kw = {"env": env, "cwd": str(ws_root), "stdin": subprocess.DEVNULL, "stdout": log, "stderr": subprocess.STDOUT, "close_fds": True}
1525
+ if os.name == "nt":
1526
+ kw["creationflags"] = (getattr(subprocess, "DETACHED_PROCESS", 0x8) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x200)
1527
+ | getattr(subprocess, "CREATE_NO_WINDOW", 0x8000000))
1528
+ else:
1529
+ kw["start_new_session"] = True
1530
+ return subprocess.Popen(cmd, **kw)
1531
+
1532
+
1533
+ def open_queue(cfg, opener=None, probe=probe_ui, launcher=start_ui, wait_s=30.0, sleep=time.sleep):
1534
+ """Open the web UI at the review queue, starting `ecadlib ui` for the workspace when nothing answers."""
1535
+ port, ws = cfg["port"], Path(cfg["workspace"])
1536
+ url = f"http://127.0.0.1:{port}/{QUEUE_ANCHOR}"
1537
+ kind, served = probe(port)
1538
+ started = False
1539
+ if kind == "other":
1540
+ return {"status": "error", "message": f"port {port} is used by another program",
1541
+ "fix": f"set port= to a free port in {INI}"}
1542
+ if kind == "ecadlib" and served and Path(served).resolve() != ws.resolve():
1543
+ return {"status": "error", "message": f"port {port} already serves another ecadlib workspace ({served})",
1544
+ "fix": f"stop that `ecadlib ui`, or set port= to another port in {INI}"}
1545
+ if kind is None:
1546
+ ws.mkdir(parents=True, exist_ok=True)
1547
+ launcher(ws, port)
1548
+ started, t0 = True, time.monotonic()
1549
+ while probe(port)[0] is None:
1550
+ if time.monotonic() - t0 > wait_s:
1551
+ return {"status": "error", "message": f"the ecadlib web UI did not start on port {port} within {int(wait_s)} s",
1552
+ "fix": f"see {ws / 'altium_menu_ui.log'}, or run `ecadlib ui --workspace \"{ws}\"` by hand"}
1553
+ sleep(0.3)
1554
+ if opener is not False and not _open(url, opener):
1555
+ return {"status": "error", "message": f"could not open a browser at {url}", "fix": "open that address yourself",
1556
+ "url": url}
1557
+ return {"status": "ok", "message": f"review queue: {url}" + (" (web UI started)" if started else ""), "url": url,
1558
+ "started": "1" if started else "0"}
1559
+
1560
+
1561
+ def run_action(cfg, action, req, opener=None, progress=None):
1562
+ if action == "add-part":
1563
+ hints = {k: req.get(k, "") for k in ("value", "footprint", "lcsc", "description", "datasheet", "designator")}
1564
+ return add_part(cfg, req.get("mpn", ""), req.get("manufacturer", ""), progress=progress, **hints)
1565
+ if action == "check-bom": # the dialog (dialog=1) shows the counts and opens the report on request
1566
+ return check_bom(cfg, req.get("project", ""), opener=False if req.get("dialog") == "1" else opener,
1567
+ progress=progress)
1568
+ if action == "fill":
1569
+ return fill(cfg, req.get("project", ""), progress=progress)
1570
+ if action == "open": # a report or review the dialog offers: only files in the workspace
1571
+ return open_file(cfg, req.get("path", ""), opener=opener)
1572
+ if action == "queue":
1573
+ return open_queue(cfg, opener=opener)
1574
+ raise ValueError(f"unknown action {action!r} (one of {', '.join(ACTIONS)})")
1575
+
1576
+
1577
+ def _error(e):
1578
+ msg = str(e) or type(e).__name__
1579
+ fix = ""
1580
+ if isinstance(e, FileNotFoundError) or "no such file" in msg:
1581
+ fix = "save the project, then try again"
1582
+ elif "NEXAR_TOKEN" in msg:
1583
+ fix = "set NEXAR_TOKEN in your environment, then restart Altium"
1584
+ elif "ecadlib.toml" in msg or "rules" in msg.lower():
1585
+ fix = "`ecadlib rules check` shows the problem in ecadlib.toml"
1586
+ return {"status": "error", "message": msg if isinstance(e, (ValueError, OSError)) else f"{type(e).__name__}: {msg}",
1587
+ "fix": fix or "see log.txt in the job folder; `ecadlib doctor` checks the setup"}
1588
+
1589
+
1590
+ def run_job(job, opener=None):
1591
+ """What the menu runs: read <job>/request.ini, do it, write <job>/result.ini. Returns the result dict."""
1592
+ job = Path(job)
1593
+ try:
1594
+ req = dict(read_ini(job / "request.ini")["request"])
1595
+ cfg = load_config(req.get("config") or job.parent.parent / INI)
1596
+ res = run_action(cfg, req.get("action", ""), req, opener=opener, progress=Progress(job))
1597
+ except Exception as e: # every failure becomes one dialog line with a fix
1598
+ res = _error(e)
1599
+ write_result(job / "result.ini", res)
1600
+ prune_jobs(job.parent, keep=job)
1601
+ return res
1602
+
1603
+
1604
+ def prune_jobs(jobs_dir, days=14, keep=None, now=None):
1605
+ """Remove finished job folders (exit.txt present) older than `days`; returns how many."""
1606
+ import shutil
1607
+ now, n = now or time.time(), 0
1608
+ for d in sorted(Path(jobs_dir).iterdir()) if Path(jobs_dir).is_dir() else []:
1609
+ if d.is_dir() and d != keep and (d / "exit.txt").is_file() and now - (d / "exit.txt").stat().st_mtime > days * 86400:
1610
+ shutil.rmtree(d, ignore_errors=True)
1611
+ n += 1
1612
+ return n
1613
+
1614
+
1615
+ # ------------------------------------------------------------------ CLI
1616
+ def _cfg_from_args(a):
1617
+ if getattr(a, "config", None):
1618
+ cfg = load_config(a.config)
1619
+ else:
1620
+ ws = Path(a.workspace or "ecadlib_workspace")
1621
+ cfg = {"workspace": ws, "library": Path(a.library) if a.library else ws / "altium_library", "name": a.name,
1622
+ "port": a.port, "offline": a.offline}
1623
+ if a.workspace and getattr(a, "config", None):
1624
+ cfg["workspace"] = Path(a.workspace)
1625
+ if a.library and getattr(a, "config", None):
1626
+ cfg["library"] = Path(a.library)
1627
+ if a.offline:
1628
+ cfg["offline"] = True
1629
+ return cfg
1630
+
1631
+
1632
+ def main(argv=None):
1633
+ ap = argparse.ArgumentParser(prog="ecadlib altium-menu",
1634
+ description="An 'ecadlib' menu in Altium Designer (DelphiScript script project) and the "
1635
+ "actions it runs",
1636
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1637
+ epilog="examples:\n ecadlib altium-menu build --out D:/ecadlib-menu --workspace D:/ecadlib-ws "
1638
+ "--to D:/libs/ecadlib\n ecadlib altium-menu add-part LM358DR --manufacturer TI "
1639
+ "--workspace D:/ecadlib-ws --result-file r.ini\n"
1640
+ "then in Altium: Preferences > Scripting System > Global Projects > ecadlib_menu.PrjScr")
1641
+ sub = ap.add_subparsers(dest="cmd", required=True)
1642
+ b = sub.add_parser("build", help="write the script project, settings, runner and (with a Windows folder) the menu file")
1643
+ b.add_argument("-o", "--out", default="out/altium_menu", help="folder for the script project (default %(default)s)")
1644
+ b.add_argument("--workspace", help="ecadlib workspace the menu uses (default: 'workspace' next to the script)")
1645
+ b.add_argument("--to", dest="library", metavar="DIR",
1646
+ help="Altium library folder for ready parts, as in dblib-sync --to (default: <workspace>/altium_library)")
1647
+ b.add_argument("--name", default="ecadlib", help="library name (default %(default)s)")
1648
+ b.add_argument("--command", default="", help='how to run ecadlib on that PC (default on Windows: "<this Python>" -m '
1649
+ "ecadlib, so a venv install works; elsewhere: find it: PATH, py -3, python)")
1650
+ b.add_argument("--win-dir", help="the Windows folder --out will be in on the Altium PC (for the ready-made menu file)")
1651
+ b.add_argument("--port", type=int, default=8765, help="web UI port for Open review queue (default %(default)s)")
1652
+ b.add_argument("--console", action="store_true", help="show ecadlib's console window (hidden=0)")
1653
+ b.add_argument("--timeout", type=int, default=1800, help="seconds before the script asks whether to keep waiting")
1654
+ b.add_argument("--offline", action="store_true", help="offline=1: cached downloads and standard parts only")
1655
+ b.add_argument("--json", action="store_true")
1656
+ for name, hlp in (("add-part", "build one part from its MPN and put it into the library"),
1657
+ ("check-bom", "check an Altium project's BOM against the library; HTML report"),
1658
+ ("fill", "librarian on a project's (or BOM's) parts, ready ones into the library"),
1659
+ ("queue", "open the web UI at the review queue (start it if needed)")):
1660
+ s = sub.add_parser(name, help=hlp)
1661
+ if name == "add-part":
1662
+ s.add_argument("mpn")
1663
+ s.add_argument("--manufacturer", default="")
1664
+ for h in ("value", "footprint", "lcsc", "description", "datasheet", "designator"):
1665
+ s.add_argument(f"--{h}", default="", help=f"optional {h} hint for the BOM line")
1666
+ elif name in ("check-bom", "fill"):
1667
+ s.add_argument("project", help=".PrjPcb / .SchDoc (or a BOM file for fill)")
1668
+ if name == "check-bom":
1669
+ s.add_argument("--no-open", action="store_true", help="do not open the report")
1670
+ if name == "queue":
1671
+ s.add_argument("--no-open", action="store_true", help="do not open a browser (print the address)")
1672
+ s.add_argument("--config", help=f"read workspace / library / name / port from this {INI}")
1673
+ s.add_argument("--workspace", help="ecadlib workspace (default ecadlib_workspace)")
1674
+ s.add_argument("--to", dest="library", metavar="DIR",
1675
+ help="Altium library folder, as in dblib-sync --to (default <workspace>/altium_library)")
1676
+ s.add_argument("--name", default="ecadlib")
1677
+ s.add_argument("--port", type=int, default=8765)
1678
+ s.add_argument("--offline", action="store_true")
1679
+ s.add_argument("--result-file", help="also write the result as the INI file the Altium script reads")
1680
+ s.add_argument("--json", action="store_true", help="print the result as JSON")
1681
+ r = sub.add_parser("run-job", help="what the Altium script runs: JOB/request.ini -> JOB/result.ini")
1682
+ r.add_argument("job")
1683
+ m = sub.add_parser("install-menu", help="put the ecadlib menu into Altium's top menu bar (Altium closed)")
1684
+ m.add_argument("--out", default="out/altium_menu", help="the folder `build` wrote (default %(default)s)")
1685
+ m.add_argument("--altium-folder", action="append", help="an Altium settings folder (default: every "
1686
+ "%%APPDATA%%\\Altium\\Altium Designer {...})")
1687
+ m.add_argument("--remove", action="store_true", help="take the ecadlib menu out again")
1688
+ a = ap.parse_args(argv)
1689
+ if a.cmd == "install-menu":
1690
+ try:
1691
+ rep = install_menu_bar(a.out, a.altium_folder, remove=a.remove)
1692
+ except (OSError, ValueError) as e:
1693
+ print(f"ecadlib altium-menu: {e}", file=sys.stderr)
1694
+ return 2
1695
+ what = rep.get("removed") if a.remove else rep.get("installed")
1696
+ print(("removed the ecadlib menu from " if a.remove else "added the ecadlib menu to ") + (", ".join(what) or
1697
+ "nothing (already as wanted)") + "".join(f"\n backup: {b}" for b in rep["backups"]))
1698
+ if not a.remove:
1699
+ print(" start Altium: the ecadlib menu is in the schematic, PCB and library editors, before Window")
1700
+ return 0
1701
+ if a.cmd == "build":
1702
+ try:
1703
+ rep = build(a.out, a.workspace, a.library, a.name, a.command, a.win_dir, a.port, not a.console, a.timeout,
1704
+ a.offline)
1705
+ except (OSError, ValueError) as e:
1706
+ print(f"ecadlib altium-menu: {e}", file=sys.stderr)
1707
+ return 2
1708
+ if a.json:
1709
+ print(json.dumps(rep, indent=2))
1710
+ else:
1711
+ for p in rep["problems"]:
1712
+ print(f" PROBLEM {p}")
1713
+ print(f"wrote {', '.join(rep['written']) or 'nothing new'} in {a.out}" +
1714
+ (f" (kept your {', '.join(rep['kept'])})" if rep["kept"] else ""))
1715
+ if rep["command_filled"]:
1716
+ print(f" {INI}: command= was empty; set it to {rep['command']}")
1717
+ print(f" runs ecadlib with: {rep['command'] or 'ecadlib on PATH, else py -3 / python (set command= in ' + INI + ' for a venv)'}")
1718
+ print(f" in Altium: Preferences > Scripting System > Global Projects > Install from file > {PROJECT}; "
1719
+ f"then Customize to put {', '.join(rep['procedures'])} on a menu (see {README})")
1720
+ if not rep["menu_file"]:
1721
+ print(f" no {PREFS}: give --win-dir (the folder on the Altium PC) for the ready-made menu file")
1722
+ return 1 if rep["problems"] else 0
1723
+ if a.cmd == "run-job":
1724
+ res = run_job(a.job)
1725
+ print(json.dumps(res, indent=2))
1726
+ return 0
1727
+ cfg = _cfg_from_args(a)
1728
+ opener = False if getattr(a, "no_open", False) else None
1729
+ req = {"mpn": getattr(a, "mpn", ""), "manufacturer": getattr(a, "manufacturer", ""),
1730
+ "project": str(Path(a.project).resolve()) if getattr(a, "project", None) else ""}
1731
+ for h in ("value", "footprint", "lcsc", "description", "datasheet", "designator"):
1732
+ req[h] = getattr(a, h, "")
1733
+ try:
1734
+ res = run_action(cfg, a.cmd, req, opener=opener)
1735
+ except Exception as e:
1736
+ if os.environ.get("ECADLIB_DEBUG"):
1737
+ raise
1738
+ res = _error(e)
1739
+ if a.result_file:
1740
+ write_result(a.result_file, res)
1741
+ if a.json:
1742
+ print(json.dumps(res, indent=2))
1743
+ else:
1744
+ print(f"{res['status']}: {res.get('message', '')}")
1745
+ if res.get("fix"):
1746
+ print(f" fix: {res['fix']}")
1747
+ return 0 if res["status"] in ("ok", "ready") else 1
1748
+
1749
+
1750
+ if __name__ == "__main__":
1751
+ sys.exit(main())