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