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/hub.py
ADDED
|
@@ -0,0 +1,1490 @@
|
|
|
1
|
+
"""Library hub: your whole parts library across tools, every part once, with a column per tool.
|
|
2
|
+
|
|
3
|
+
ecadlib hub [--workspace W] [--lib DIR ...] [--have altium --lack kicad] [--state different,behind] [--json]
|
|
4
|
+
(or: the Library page of `ecadlib ui`, or the MCP tools library_hub / library_hub_part)
|
|
5
|
+
|
|
6
|
+
What it reads (nothing is written to your libraries; the hub keeps its own notes in <workspace>/hub/):
|
|
7
|
+
ecadlib's own parts every librarian plan in the workspace (<workspace>/librarian/*/plan.json): the Altium
|
|
8
|
+
.SchLib / .PcbLib each part was built into, the KiCad library kicad-dblib wrote for that BOM
|
|
9
|
+
(<workspace>/kicad/<bom>/), and the Altium 365 component it was pushed to or pulled from.
|
|
10
|
+
your libraries the --lib folders plus [hub] libraries in ecadlib.toml: .SchLib / .PcbLib, .DbLib / .SvnDbLib
|
|
11
|
+
(with their database), .kicad_sym, .pretty/*.kicad_mod, .kicad_dbl, and Altium 365 component
|
|
12
|
+
lists (a365 pull's workspace_plan.json, check-a365 --state files). Same readers as `ecadlib
|
|
13
|
+
init`, dedupe and where-used. Files inside the workspace are read through the plans instead.
|
|
14
|
+
other tools Fusion (Eagle .lbr) and EasyEDA Pro columns fill in once their reader is registered with
|
|
15
|
+
register_tool(); until then the column says "no reader yet".
|
|
16
|
+
|
|
17
|
+
One row per part: copies are grouped by normalised MPN (dedupe.mpn_key: packaging suffixes stripped), or by name
|
|
18
|
+
when a copy has no MPN. Each tool's cell is
|
|
19
|
+
missing no copy in that tool
|
|
20
|
+
same the copy's pads, pins and value match the newest copy that has them
|
|
21
|
+
different something differs from the newest copy (the detail says what: pad count, a pad's size, a pin name, the value)
|
|
22
|
+
behind different, and last changed [hub] stale_days (default 14) or more before the newest copy: "KiCad is
|
|
23
|
+
3 weeks behind Altium"
|
|
24
|
+
present there, but nothing to compare it with (an Altium 365 component without downloaded files)
|
|
25
|
+
|
|
26
|
+
Last updated / by: the newest change of each copy: a git commit when the file is in a git repository and has no
|
|
27
|
+
uncommitted change (author and date), the Altium 365 revision date, else the file's own date ("by" is then whoever
|
|
28
|
+
ecadlib recorded doing it through the web app, or "not recorded"). Each scan is compared with the last one
|
|
29
|
+
(<workspace>/hub/state.json); added, changed and removed copies go into <workspace>/hub/history.jsonl together with
|
|
30
|
+
what people did through the web app (fill, pick, push, fix). A part's history also shows its sign-offs and Altium 365
|
|
31
|
+
syncs. Where-used reads the Altium projects under [hub] projects; lifecycle comes from upkeep's lifecycle checks and
|
|
32
|
+
Altium 365's lifecycle state.
|
|
33
|
+
|
|
34
|
+
Settings ([hub] in ecadlib.toml): libraries = ["D:/Altium/Libraries", ...], projects = ["D:/Projects"],
|
|
35
|
+
stale_days = 14."""
|
|
36
|
+
import argparse, contextlib, datetime, hashlib, json, os, re, sqlite3, subprocess, sys, threading
|
|
37
|
+
from pathlib import Path, PureWindowsPath
|
|
38
|
+
|
|
39
|
+
from .fsutil import write_text_atomic
|
|
40
|
+
|
|
41
|
+
TOOLS = [("altium", "Altium"), ("altium365", "Altium 365"), ("kicad", "KiCad"), ("fusion", "Fusion"), ("easyeda", "EasyEDA")]
|
|
42
|
+
LABEL = dict(TOOLS)
|
|
43
|
+
STATES = ("missing", "same", "present", "different", "behind")
|
|
44
|
+
RANK = {"missing": 0, "same": 1, "present": 1, "different": 2, "behind": 3}
|
|
45
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", "http_cache", ".venv", "venv", ".svn"}
|
|
46
|
+
MAX_FILES = 5000
|
|
47
|
+
HISTORY = "history.jsonl"
|
|
48
|
+
HISTORY_MAX = 4 * 1024 * 1024
|
|
49
|
+
STATE = "state.json"
|
|
50
|
+
BULK = 40 # more added/removed copies than this in one file in one scan: one event for the file
|
|
51
|
+
NOT_PARAMS = {"comment", "description", "designator", "hiddennetname", "value"}
|
|
52
|
+
MPN_FIELDS = ("manufacturer part number", "manufacturer part", "mpn", "mfr part number", "manufacturer_part_number", "mfr_pn", "mpn1",
|
|
53
|
+
"manufacturer part number 1")
|
|
54
|
+
MFR_FIELDS = ("manufacturer", "manufacturer 1", "mfr", "manufacturer name")
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ other tools (Fusion, EasyEDA, plugins)
|
|
57
|
+
_READERS = {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def register_tool(tool, label, exts, read, workspace_dirs=(), fix=None):
|
|
61
|
+
"""A reader for another tool's library files, so the hub fills that tool's column.
|
|
62
|
+
|
|
63
|
+
tool: "fusion", "easyeda" or a new id (a new id gets its own column). exts: file suffixes, e.g. (".lbr",).
|
|
64
|
+
read(path) -> [{"name", "mpn", "symbol", "footprint", "prefix", "fields": {..},
|
|
65
|
+
"pads": [(number, x_mm, y_mm, w_mm, h_mm, rot_deg, drilled)], "pins": [(number, name)]}]
|
|
66
|
+
with y pointing up (Altium's and dedupe's frame). workspace_dirs: folders inside the workspace where ecadlib's own
|
|
67
|
+
exporter writes this tool's files (scanned as ecadlib's copies). fix(ws, plan_path, item) -> dict: optional
|
|
68
|
+
"make this tool's copy" action for a part ecadlib built (a fix with wants_bom = True also gets bom=)."""
|
|
69
|
+
if tool not in LABEL:
|
|
70
|
+
TOOLS.append((tool, label))
|
|
71
|
+
LABEL[tool] = label
|
|
72
|
+
_READERS[tool] = {"label": label, "exts": tuple(e.lower() for e in exts), "read": read,
|
|
73
|
+
"workspace_dirs": tuple(workspace_dirs), "fix": fix}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _plugin_readers():
|
|
77
|
+
"""Readers shipped as plugins: entry point group ecadlib.hub_tools, each a callable that calls register_tool."""
|
|
78
|
+
from . import plugins as PL
|
|
79
|
+
for ep in PL._entry_points("ecadlib.hub_tools"):
|
|
80
|
+
with contextlib.suppress(Exception):
|
|
81
|
+
ep.load()()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _builtin_readers():
|
|
85
|
+
"""Fusion / EasyEDA readers (hub_tools, on eagle-export's and easyeda-export's read-back), when this ecadlib has them."""
|
|
86
|
+
from . import hub_tools
|
|
87
|
+
hub_tools.hub_register(lambda tool, *a, **k: None if tool in _READERS else register_tool(tool, *a, **k)) # a plugin's wins
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ small helpers
|
|
91
|
+
_LOCK = threading.RLock()
|
|
92
|
+
_PARSED = {} # (path, mtime_ns, size, kind) -> parsed result
|
|
93
|
+
_GIT = {} # (path, mtime_ns) -> (who, when) or None
|
|
94
|
+
_BUILT = {} # workspace root -> (signature, result)
|
|
95
|
+
_DD_CFG = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cfg():
|
|
99
|
+
global _DD_CFG
|
|
100
|
+
if _DD_CFG is None:
|
|
101
|
+
from . import dedupe as DD
|
|
102
|
+
_DD_CFG = DD.load_config()
|
|
103
|
+
return _DD_CFG
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def mpn_key(s):
|
|
107
|
+
from . import dedupe as DD
|
|
108
|
+
return DD.mpn_key(s, _cfg())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def part_key(mpn, name):
|
|
112
|
+
k = mpn_key(mpn) if mpn else ""
|
|
113
|
+
return k or "name:" + re.sub(r"\s+", " ", str(name or "")).strip().upper()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def now():
|
|
117
|
+
return datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def iso(ts):
|
|
121
|
+
"""A timestamp (epoch seconds or ISO text) -> ISO 8601 in UTC, or None."""
|
|
122
|
+
if ts in (None, ""):
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
if isinstance(ts, (int, float)):
|
|
126
|
+
d = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
|
|
127
|
+
else:
|
|
128
|
+
d = datetime.datetime.fromisoformat(str(ts).strip().replace("Z", "+00:00"))
|
|
129
|
+
if d.tzinfo is None:
|
|
130
|
+
d = d.replace(tzinfo=datetime.timezone.utc)
|
|
131
|
+
return d.astimezone(datetime.timezone.utc).replace(microsecond=0).isoformat()
|
|
132
|
+
except (ValueError, OverflowError, OSError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _days(a, b):
|
|
137
|
+
"""Days from ISO time a to ISO time b (b later -> positive)."""
|
|
138
|
+
try:
|
|
139
|
+
return (datetime.datetime.fromisoformat(b) - datetime.datetime.fromisoformat(a)).total_seconds() / 86400
|
|
140
|
+
except (TypeError, ValueError):
|
|
141
|
+
return 0.0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def age_words(days):
|
|
145
|
+
days = abs(days)
|
|
146
|
+
if days < 1:
|
|
147
|
+
return "less than a day"
|
|
148
|
+
if days < 14:
|
|
149
|
+
return f"{int(days)} day{'s' if int(days) != 1 else ''}"
|
|
150
|
+
if days < 60:
|
|
151
|
+
return f"{int(days // 7)} weeks"
|
|
152
|
+
if days < 730:
|
|
153
|
+
return f"{int(days // 30)} months"
|
|
154
|
+
return f"{int(days // 365)} years"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _stat(p):
|
|
158
|
+
try:
|
|
159
|
+
st = Path(p).stat()
|
|
160
|
+
return st.st_mtime_ns, st.st_size
|
|
161
|
+
except OSError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parsed(path, kind, fn):
|
|
166
|
+
"""fn(path), remembered while the file's date and size stay the same. Errors are cached too (as the exception)."""
|
|
167
|
+
st = _stat(path)
|
|
168
|
+
key = (str(path), st, kind)
|
|
169
|
+
with _LOCK:
|
|
170
|
+
if key in _PARSED:
|
|
171
|
+
v = _PARSED[key]
|
|
172
|
+
if isinstance(v, Exception):
|
|
173
|
+
raise v
|
|
174
|
+
return v
|
|
175
|
+
try:
|
|
176
|
+
v = fn(path)
|
|
177
|
+
except Exception as e: # noqa: BLE001 - one unreadable library file is reported, not fatal
|
|
178
|
+
v = e
|
|
179
|
+
with _LOCK:
|
|
180
|
+
if len(_PARSED) > 20000:
|
|
181
|
+
_PARSED.clear()
|
|
182
|
+
_PARSED[key] = v
|
|
183
|
+
if isinstance(v, Exception):
|
|
184
|
+
raise v
|
|
185
|
+
return v
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _git_root(p):
|
|
189
|
+
for d in Path(p).resolve().parents:
|
|
190
|
+
if (d / ".git").exists():
|
|
191
|
+
return d
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _git_last(path, budget):
|
|
196
|
+
"""(author, ISO date) of the last commit of a file that has no uncommitted change, else None. budget: a one-item
|
|
197
|
+
list counting the git calls this build may still make (big libraries: the first files only, the rest next time)."""
|
|
198
|
+
st = _stat(path)
|
|
199
|
+
key = (str(path), st)
|
|
200
|
+
with _LOCK:
|
|
201
|
+
if key in _GIT:
|
|
202
|
+
return _GIT[key]
|
|
203
|
+
root = _git_root(path)
|
|
204
|
+
if not root or budget[0] <= 0:
|
|
205
|
+
return None
|
|
206
|
+
budget[0] -= 1
|
|
207
|
+
res = None
|
|
208
|
+
try:
|
|
209
|
+
dirty = subprocess.run(["git", "-C", str(root), "status", "--porcelain", "--", str(path)], capture_output=True,
|
|
210
|
+
text=True, timeout=5)
|
|
211
|
+
if dirty.returncode == 0 and not dirty.stdout.strip():
|
|
212
|
+
r = subprocess.run(["git", "-C", str(root), "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--", str(path)],
|
|
213
|
+
capture_output=True, text=True, timeout=5)
|
|
214
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
215
|
+
name, email, when = r.stdout.strip().split("\x1f")
|
|
216
|
+
res = (f"{name} <{email}>" if email else name, iso(when))
|
|
217
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
218
|
+
res = None
|
|
219
|
+
with _LOCK:
|
|
220
|
+
_GIT[key] = res
|
|
221
|
+
return res
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _pin_name(s):
|
|
225
|
+
s = re.sub(r"[\\~{}]", "", str(s or "")).strip().upper()
|
|
226
|
+
return s
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------ readers: Altium
|
|
230
|
+
def _schlib(path):
|
|
231
|
+
"""[{name, params, prefix, footprint, description, pins: [(num, name)]}] of a .SchLib."""
|
|
232
|
+
from . import altium_lib as AL
|
|
233
|
+
_, syms = AL.read_schlib(path)
|
|
234
|
+
out = []
|
|
235
|
+
for s in syms:
|
|
236
|
+
params, prefix, fp, desc = {}, "", "", ""
|
|
237
|
+
for r in s["records"]:
|
|
238
|
+
rt = r.get("RECORD")
|
|
239
|
+
if rt == "1":
|
|
240
|
+
desc = r.get("COMPONENTDESCRIPTION", "")
|
|
241
|
+
elif rt == "34":
|
|
242
|
+
prefix = re.match(r"[A-Za-z]*", r.get("TEXT", "")).group(0).upper()
|
|
243
|
+
elif rt == "41" and r.get("NAME") and "OWNERINDEX" not in r:
|
|
244
|
+
params[r["NAME"]] = r.get("TEXT", "")
|
|
245
|
+
elif rt == "45" and r.get("MODELTYPE", "").upper() == "PCBLIB" and not fp:
|
|
246
|
+
fp = r.get("MODELNAME", "")
|
|
247
|
+
pins = sorted({(str(r["num"]), _pin_name(r.get("name"))) for r in s["records"] if r.get("RECORD") == "2"})
|
|
248
|
+
out.append({"name": s["name"], "params": params, "prefix": prefix, "footprint": fp, "description": desc, "pins": pins})
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _pcblib(path):
|
|
253
|
+
"""{footprint name: [pad tuples]} of a .PcbLib (dedupe's geometry, y up)."""
|
|
254
|
+
from . import altium_lib as AL, dedupe as DD
|
|
255
|
+
return {fp["name"]: DD.fp_geometry_pcblib(fp)["pads"] for fp in AL.read_pcblib(path)}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _dblib(path):
|
|
259
|
+
"""Rows of a .DbLib / .SvnDbLib's database (dedupe.Collector): [{name, mpn, symbol, footprint, table}]."""
|
|
260
|
+
from . import dedupe as DD
|
|
261
|
+
col = DD.Collector(_cfg(), log=lambda *a, **k: None)
|
|
262
|
+
if Path(path).suffix.lower() == ".svndblib":
|
|
263
|
+
text = Path(path).read_text(errors="replace", encoding="utf-8")
|
|
264
|
+
if not re.search(r"(?:Data Source|Database|DBQ)=", text, re.I):
|
|
265
|
+
return {"rows": [], "errors": [f"{Path(path).name}: no database in ConnectionString"]}
|
|
266
|
+
col.add_dblib(path)
|
|
267
|
+
rows = [{"name": p["name"], "mpn": p["mpn"] if p["mpn"] != p["name"] else "", "symbol": p["symbol"],
|
|
268
|
+
"footprint": p["footprint"], "table": p["where"].rsplit("[", 1)[-1].rstrip("]")} for p in col.parts]
|
|
269
|
+
return {"rows": rows, "errors": col.errors}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------ readers: KiCad
|
|
273
|
+
def _kicad_pins(sym, by_name):
|
|
274
|
+
pins = []
|
|
275
|
+
for sub in [sym] + [c for c in sym if isinstance(c, list) and c and c[0] == "symbol"]:
|
|
276
|
+
for p in (c for c in sub if isinstance(c, list) and c and c[0] == "pin"):
|
|
277
|
+
name = next((c[1] for c in p if isinstance(c, list) and c and c[0] == "name" and len(c) > 1), "")
|
|
278
|
+
num = next((c[1] for c in p if isinstance(c, list) and c and c[0] == "number" and len(c) > 1), "")
|
|
279
|
+
pins.append((str(num), _pin_name(name)))
|
|
280
|
+
ext = next((c[1] for c in sym if isinstance(c, list) and c and c[0] == "extends" and len(c) > 1), None)
|
|
281
|
+
if not pins and ext and ext in by_name:
|
|
282
|
+
return _kicad_pins(by_name[ext], by_name)
|
|
283
|
+
return sorted(set(pins))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _kicad_sym(path):
|
|
287
|
+
"""[{name, props, prefix, footprint, pins}] of a .kicad_sym."""
|
|
288
|
+
from . import sexpr
|
|
289
|
+
t = sexpr.parse(Path(path).read_text(errors="replace", encoding="utf-8"))
|
|
290
|
+
syms = list(sexpr.find(t, "symbol"))
|
|
291
|
+
by_name = {s[1]: s for s in syms}
|
|
292
|
+
out = []
|
|
293
|
+
for s in syms:
|
|
294
|
+
props = {p[1]: p[2] for p in sexpr.find(s, "property") if len(p) > 2}
|
|
295
|
+
ext = next((c[1] for c in s if isinstance(c, list) and c and c[0] == "extends" and len(c) > 1), None)
|
|
296
|
+
if ext in by_name:
|
|
297
|
+
props = {**{p[1]: p[2] for p in sexpr.find(by_name[ext], "property") if len(p) > 2}, **props}
|
|
298
|
+
out.append({"name": s[1], "props": props, "prefix": re.match(r"[A-Za-z]*", props.get("Reference", "")).group(0).upper(),
|
|
299
|
+
"footprint": props.get("Footprint", ""), "pins": _kicad_pins(s, by_name)})
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _kicad_mod(path):
|
|
304
|
+
from . import dedupe as DD
|
|
305
|
+
return DD.fp_geometry_kicad(Path(path).read_text(errors="replace", encoding="utf-8"))["pads"]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _kicad_dbl(path):
|
|
309
|
+
"""Rows of a KiCad database library (.kicad_dbl) whose ODBC source is an SQLite file next to it."""
|
|
310
|
+
d = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
311
|
+
cs = (d.get("source") or {}).get("connection_string") or ""
|
|
312
|
+
m = re.search(r"Database=([^;]+)", cs, re.I)
|
|
313
|
+
if not m:
|
|
314
|
+
return {"rows": [], "errors": [f"{Path(path).name}: only SQLite database libraries are read (no Database= in its connection string)"]}
|
|
315
|
+
db = Path(m.group(1).strip().replace("${CWD}", str(Path(path).parent)))
|
|
316
|
+
if not db.is_file():
|
|
317
|
+
db = Path(path).parent / PureWindowsPath(m.group(1).strip()).name
|
|
318
|
+
if not db.is_file():
|
|
319
|
+
return {"rows": [], "errors": [f"{Path(path).name}: database {db.name} not found next to it"]}
|
|
320
|
+
rows = []
|
|
321
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
322
|
+
try:
|
|
323
|
+
for lib in d.get("libraries") or []:
|
|
324
|
+
table, key = lib.get("table"), lib.get("key") or "Part ID"
|
|
325
|
+
fields = {f.get("name"): f.get("column") for f in lib.get("fields") or [] if f.get("column")}
|
|
326
|
+
try:
|
|
327
|
+
cur = con.execute(f'SELECT * FROM "{str(table).replace(chr(34), "")}"')
|
|
328
|
+
except sqlite3.Error:
|
|
329
|
+
continue
|
|
330
|
+
cols = [c[0] for c in cur.description]
|
|
331
|
+
for r in cur:
|
|
332
|
+
row = dict(zip(cols, ("" if v is None else str(v) for v in r)))
|
|
333
|
+
vals = {n: row.get(c, "") for n, c in fields.items()}
|
|
334
|
+
rows.append({"name": row.get(key, ""), "symbol": row.get(lib.get("symbols") or "Symbol", ""),
|
|
335
|
+
"footprint": row.get(lib.get("footprints") or "Footprint", ""), "fields": vals,
|
|
336
|
+
"table": table})
|
|
337
|
+
finally:
|
|
338
|
+
con.close()
|
|
339
|
+
return {"rows": rows, "errors": []}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------ copies
|
|
343
|
+
def _field(d, names):
|
|
344
|
+
low = {str(k).strip().lower(): v for k, v in (d or {}).items()}
|
|
345
|
+
return next((str(low[n]).strip() for n in names if str(low.get(n) or "").strip()), "")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _copy(tool, name, origin, path, **kw):
|
|
349
|
+
c = {"tool": tool, "name": str(name), "origin": origin, "path": str(path) if path else None, "mpn": "",
|
|
350
|
+
"manufacturer": "", "value": "", "description": "", "symbol": "", "footprint": "", "prefix": "",
|
|
351
|
+
"pins": None, "pads": None, "updated": None, "updated_by": None, "updated_how": None, "revision": None,
|
|
352
|
+
"plan": None, "item": None, "status": None, "lifecycle": None, "where": ""}
|
|
353
|
+
c.update(kw)
|
|
354
|
+
return c
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class Scan:
|
|
358
|
+
"""One pass over the libraries and the workspace -> copies, plus what could not be read."""
|
|
359
|
+
|
|
360
|
+
def __init__(self, ws, libraries, max_files=MAX_FILES):
|
|
361
|
+
self.ws, self.root = ws, Path(ws.root).resolve()
|
|
362
|
+
from .hub_copy import library_dir
|
|
363
|
+
self.hublib = library_dir(ws) # parts the hub copied between tools: ecadlib's, read like a library
|
|
364
|
+
self.libraries = [Path(p).expanduser() for p in libraries] + ([self.hublib] if self.hublib.is_dir() else [])
|
|
365
|
+
self.max_files = max_files
|
|
366
|
+
self.copies, self.errors, self.files, self.warnings = [], [], [], []
|
|
367
|
+
self.sigs = []
|
|
368
|
+
|
|
369
|
+
def _sig(self, p):
|
|
370
|
+
st = _stat(p)
|
|
371
|
+
if st:
|
|
372
|
+
self.sigs.append((str(p), st))
|
|
373
|
+
|
|
374
|
+
def _read(self, p, kind, fn):
|
|
375
|
+
try:
|
|
376
|
+
return _parsed(p, kind, fn)
|
|
377
|
+
except Exception as e: # noqa: BLE001
|
|
378
|
+
self.errors.append(f"{self._show(p)}: {type(e).__name__}: {str(e)[:200]}")
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
def _show(self, p):
|
|
382
|
+
p = Path(p)
|
|
383
|
+
try:
|
|
384
|
+
return p.resolve().relative_to(self.root).as_posix()
|
|
385
|
+
except ValueError:
|
|
386
|
+
return str(p)
|
|
387
|
+
|
|
388
|
+
# -- your libraries
|
|
389
|
+
def library_files(self):
|
|
390
|
+
exts = {".schlib", ".pcblib", ".dblib", ".svndblib", ".kicad_sym", ".kicad_mod", ".kicad_dbl", ".json"}
|
|
391
|
+
exts |= {e for r in _READERS.values() for e in r["exts"]}
|
|
392
|
+
files = []
|
|
393
|
+
self.src = {}
|
|
394
|
+
for base in self.libraries:
|
|
395
|
+
if base.is_file():
|
|
396
|
+
files.append(base)
|
|
397
|
+
self.src[str(base)] = str(base)
|
|
398
|
+
continue
|
|
399
|
+
if not base.is_dir():
|
|
400
|
+
self.warnings.append(f"{base}: not found")
|
|
401
|
+
continue
|
|
402
|
+
for f in sorted(base.rglob("*")):
|
|
403
|
+
if len(files) >= self.max_files:
|
|
404
|
+
self.warnings.append(f"stopped after {self.max_files} files under {base}")
|
|
405
|
+
break
|
|
406
|
+
if (f.suffix.lower() in exts and f.is_file() and not any(x in SKIP_DIRS for x in f.relative_to(base).parts[:-1])
|
|
407
|
+
and (self.root not in f.resolve().parents or self.hublib in f.resolve().parents)):
|
|
408
|
+
files.append(f)
|
|
409
|
+
self.src.setdefault(str(f), str(base))
|
|
410
|
+
return list(dict.fromkeys(files))
|
|
411
|
+
|
|
412
|
+
def libraries_scan(self):
|
|
413
|
+
files = self.library_files()
|
|
414
|
+
for f in files:
|
|
415
|
+
self._sig(f)
|
|
416
|
+
by_suf = {}
|
|
417
|
+
for f in files:
|
|
418
|
+
by_suf.setdefault(f.suffix.lower(), []).append(f)
|
|
419
|
+
pcb = {} # footprint name -> [(file, pads)]
|
|
420
|
+
for f in by_suf.get(".pcblib", []):
|
|
421
|
+
for name, pads in (self._read(f, "pcblib", _pcblib) or {}).items():
|
|
422
|
+
pcb.setdefault(name.upper(), []).append((f, pads))
|
|
423
|
+
kmod = {} # "lib:name" and "name" -> [(file, pads)]
|
|
424
|
+
for f in by_suf.get(".kicad_mod", []):
|
|
425
|
+
pads = self._read(f, "kicad_mod", _kicad_mod)
|
|
426
|
+
if pads is not None:
|
|
427
|
+
kmod.setdefault(f"{f.parent.stem}:{f.stem}".upper(), []).append((f, pads))
|
|
428
|
+
kmod.setdefault(f.stem.upper(), []).append((f, pads))
|
|
429
|
+
schsym = {} # symbol name -> [(file, entry)]
|
|
430
|
+
sch = {f: self._read(f, "schlib", _schlib) or [] for f in by_suf.get(".schlib", [])}
|
|
431
|
+
for f, entries in sch.items():
|
|
432
|
+
for e in entries:
|
|
433
|
+
schsym.setdefault(e["name"].upper(), []).append((f, e))
|
|
434
|
+
ksym = {f: self._read(f, "kicad_sym", _kicad_sym) or [] for f in by_suf.get(".kicad_sym", [])}
|
|
435
|
+
kby = {}
|
|
436
|
+
for f, entries in ksym.items():
|
|
437
|
+
for e in entries:
|
|
438
|
+
kby.setdefault(f"{f.stem}:{e['name']}".upper(), []).append((f, e))
|
|
439
|
+
kby.setdefault(e["name"].upper(), []).append((f, e))
|
|
440
|
+
|
|
441
|
+
def near(hits, f):
|
|
442
|
+
"""The hit in the same folder as f, else the first one."""
|
|
443
|
+
return next((h for h in hits if Path(h[0]).parent == Path(f).parent), hits[0]) if hits else None
|
|
444
|
+
|
|
445
|
+
used_sch, used_ksym = set(), set()
|
|
446
|
+
# Altium database libraries: one part per row
|
|
447
|
+
for f in by_suf.get(".dblib", []) + by_suf.get(".svndblib", []):
|
|
448
|
+
got = self._read(f, "dblib", _dblib)
|
|
449
|
+
if not got:
|
|
450
|
+
continue
|
|
451
|
+
self.errors += [e for e in got["errors"]]
|
|
452
|
+
for r in got["rows"]:
|
|
453
|
+
s = near(schsym.get(r["symbol"].upper(), []), f)
|
|
454
|
+
p = near(pcb.get(r["footprint"].upper(), []), f)
|
|
455
|
+
if s:
|
|
456
|
+
used_sch.add(r["symbol"].upper())
|
|
457
|
+
self.copies.append(_copy("altium", r["name"], "library", f, mpn=r["mpn"], symbol=r["symbol"],
|
|
458
|
+
footprint=r["footprint"], pins=s[1]["pins"] if s else None,
|
|
459
|
+
prefix=s[1]["prefix"] if s else "", pads=p[1] if p else None,
|
|
460
|
+
where=f"{f.name} [{r['table']}]", files=[x for x in (f, s and s[0], p and p[0]) if x]))
|
|
461
|
+
# Altium symbols with their own parameters (integrated-library style): one part per symbol
|
|
462
|
+
for f, entries in sch.items():
|
|
463
|
+
for e in entries:
|
|
464
|
+
if e["name"].upper() in used_sch:
|
|
465
|
+
continue
|
|
466
|
+
p = near(pcb.get(e["footprint"].upper(), []), f) if e["footprint"] else None
|
|
467
|
+
self.copies.append(_copy("altium", e["name"], "library", f, mpn=_field(e["params"], MPN_FIELDS),
|
|
468
|
+
manufacturer=_field(e["params"], MFR_FIELDS), value=_field(e["params"], ("value",)),
|
|
469
|
+
description=e["description"], symbol=e["name"], footprint=e["footprint"],
|
|
470
|
+
pins=e["pins"], prefix=e["prefix"], pads=p[1] if p else None, where=f.name,
|
|
471
|
+
files=[x for x in (f, p and p[0]) if x]))
|
|
472
|
+
# KiCad database libraries: one part per row
|
|
473
|
+
for f in by_suf.get(".kicad_dbl", []):
|
|
474
|
+
got = self._read(f, "kicad_dbl", _kicad_dbl)
|
|
475
|
+
if not got:
|
|
476
|
+
continue
|
|
477
|
+
self.errors += got["errors"]
|
|
478
|
+
for r in got["rows"]:
|
|
479
|
+
s = near(kby.get(r["symbol"].upper(), []), f)
|
|
480
|
+
p = near(kmod.get(r["footprint"].upper(), []) or kmod.get(r["footprint"].split(":")[-1].upper(), []), f)
|
|
481
|
+
if s:
|
|
482
|
+
used_ksym.add(r["symbol"].upper())
|
|
483
|
+
self.copies.append(_copy("kicad", r["name"], "library", f, mpn=_field(r["fields"], MPN_FIELDS),
|
|
484
|
+
manufacturer=_field(r["fields"], MFR_FIELDS), value=_field(r["fields"], ("value",)),
|
|
485
|
+
description=_field(r["fields"], ("description",)), symbol=r["symbol"],
|
|
486
|
+
footprint=r["footprint"], pins=s[1]["pins"] if s else None,
|
|
487
|
+
prefix=s[1]["prefix"] if s else "", pads=p[1] if p else None,
|
|
488
|
+
where=f"{f.name} [{r['table']}]", files=[x for x in (f, s and s[0], p and p[0]) if x]))
|
|
489
|
+
# KiCad symbols: one part per symbol
|
|
490
|
+
for f, entries in ksym.items():
|
|
491
|
+
for e in entries:
|
|
492
|
+
if f"{f.stem}:{e['name']}".upper() in used_ksym or e["name"].upper() in used_ksym:
|
|
493
|
+
continue
|
|
494
|
+
fp = e["footprint"]
|
|
495
|
+
p = near(kmod.get(fp.upper(), []) or kmod.get(fp.split(":")[-1].upper(), []), f) if fp else None
|
|
496
|
+
pr = e["props"]
|
|
497
|
+
self.copies.append(_copy("kicad", e["name"], "library", f, mpn=_field(pr, MPN_FIELDS),
|
|
498
|
+
manufacturer=_field(pr, MFR_FIELDS),
|
|
499
|
+
value=pr.get("Value", "") if str(pr.get("Value", "")) != e["name"] else "",
|
|
500
|
+
description=pr.get("Description", "") or pr.get("ki_description", ""),
|
|
501
|
+
symbol=f"{f.stem}:{e['name']}", footprint=fp, pins=e["pins"], prefix=e["prefix"],
|
|
502
|
+
pads=p[1] if p else None, where=f.name, files=[x for x in (f, p and p[0]) if x]))
|
|
503
|
+
for c in self.copies:
|
|
504
|
+
if c["path"] and self.hublib in Path(c["path"]).resolve().parents:
|
|
505
|
+
c["origin"], c["where"] = "ecadlib", f"hub library ({c['where']})"
|
|
506
|
+
# Altium 365 component lists
|
|
507
|
+
for f in by_suf.get(".json", []):
|
|
508
|
+
self.a365_file(f, "library")
|
|
509
|
+
# other tools
|
|
510
|
+
for tool, r in _READERS.items():
|
|
511
|
+
for f in [f for e in r["exts"] for f in by_suf.get(e, [])]:
|
|
512
|
+
self.tool_file(tool, r, f, "library")
|
|
513
|
+
|
|
514
|
+
def a365_file(self, f, origin):
|
|
515
|
+
"""A check-a365 --state file ({name: snapshot}) or a list of workspace component nodes; other JSON is skipped."""
|
|
516
|
+
if not re.search(r"a365|altium.?365|components|workspace", f.name, re.I):
|
|
517
|
+
return
|
|
518
|
+
try:
|
|
519
|
+
data = _parsed(f, "json", lambda p: json.loads(Path(p).read_text(encoding="utf-8")))
|
|
520
|
+
except Exception: # noqa: BLE001 - not a component list
|
|
521
|
+
return
|
|
522
|
+
if isinstance(data, dict) and isinstance(data.get("components"), list):
|
|
523
|
+
data = data["components"]
|
|
524
|
+
if isinstance(data, dict) and data and all(isinstance(v, dict) and ("revision" in v or "symbols" in v) for v in data.values()):
|
|
525
|
+
nodes = [{"name": k, **v} for k, v in data.items()]
|
|
526
|
+
elif isinstance(data, list) and data and all(isinstance(x, dict) and "name" in x for x in data) and \
|
|
527
|
+
any("parameters" in x or "details" in x or "revision" in x for x in data):
|
|
528
|
+
nodes = data
|
|
529
|
+
else:
|
|
530
|
+
return
|
|
531
|
+
for n in nodes:
|
|
532
|
+
par = n.get("parameters")
|
|
533
|
+
d = n.get("details") or {}
|
|
534
|
+
if isinstance(par, list):
|
|
535
|
+
par = {p.get("name"): p.get("value", "") for p in par if isinstance(p, dict)}
|
|
536
|
+
elif not isinstance(par, dict):
|
|
537
|
+
par = {p["name"]: p.get("value", "") for p in d.get("parameters") or [] if isinstance(p, dict) and p.get("name")}
|
|
538
|
+
rev = n.get("revision")
|
|
539
|
+
rev = (rev or {}).get("id") if isinstance(rev, dict) else rev
|
|
540
|
+
self.copies.append(_copy("altium365", n["name"], origin, f, mpn=_field(par, MPN_FIELDS),
|
|
541
|
+
manufacturer=_field(par, MFR_FIELDS), value=_field(par, ("value",)),
|
|
542
|
+
description=str(n.get("description") or ""), symbol=str(n.get("symbol") or ""),
|
|
543
|
+
footprint=str(n.get("footprint") or ""), revision=rev,
|
|
544
|
+
lifecycle=n.get("lifecycle") if isinstance(n.get("lifecycle"), str) else None,
|
|
545
|
+
updated=iso(n.get("modified_at") or n.get("modifiedAt")), updated_how="Altium 365 revision",
|
|
546
|
+
where=f"Altium 365 ({f.name})", files=[f]))
|
|
547
|
+
self._sig(f)
|
|
548
|
+
|
|
549
|
+
def tool_file(self, tool, reader, f, origin):
|
|
550
|
+
got = self._read(f, f"tool:{tool}", reader["read"])
|
|
551
|
+
self._sig(f)
|
|
552
|
+
for e in got or []:
|
|
553
|
+
fields = e.get("fields") or {}
|
|
554
|
+
self.copies.append(_copy(tool, e.get("name") or "", origin, f, mpn=e.get("mpn") or _field(fields, MPN_FIELDS),
|
|
555
|
+
manufacturer=_field(fields, MFR_FIELDS), value=_field(fields, ("value",)),
|
|
556
|
+
description=_field(fields, ("description",)), symbol=e.get("symbol") or "",
|
|
557
|
+
footprint=e.get("footprint") or "", prefix=e.get("prefix") or "",
|
|
558
|
+
pins=sorted({(str(a), _pin_name(b)) for a, b in e["pins"]}) if e.get("pins") is not None else None,
|
|
559
|
+
pads=sorted(tuple(x) for x in e["pads"]) if e.get("pads") is not None else None,
|
|
560
|
+
where=f.name, files=[f]))
|
|
561
|
+
|
|
562
|
+
# -- ecadlib's own parts
|
|
563
|
+
def plans(self):
|
|
564
|
+
out = []
|
|
565
|
+
for plan_f in sorted((self.root / "librarian").glob("*/plan.json")):
|
|
566
|
+
self._sig(plan_f)
|
|
567
|
+
try:
|
|
568
|
+
items = _parsed(plan_f, "plan", lambda p: json.loads(Path(p).read_text(encoding="utf-8")))
|
|
569
|
+
except Exception as e: # noqa: BLE001
|
|
570
|
+
self.errors.append(f"{self._show(plan_f)}: {type(e).__name__}: {e}")
|
|
571
|
+
continue
|
|
572
|
+
if not isinstance(items, list):
|
|
573
|
+
continue
|
|
574
|
+
out.append((plan_f, items))
|
|
575
|
+
return out
|
|
576
|
+
|
|
577
|
+
def plan_bom(self, plan_f):
|
|
578
|
+
"""The workspace path of the BOM a plan was built from (library.lock.json), or None."""
|
|
579
|
+
lock = plan_f.parent / "library.lock.json"
|
|
580
|
+
try:
|
|
581
|
+
rel = json.loads(lock.read_text(encoding="utf-8"))["bom"]["file"]
|
|
582
|
+
p = (plan_f.parent / rel).resolve()
|
|
583
|
+
return p.relative_to(self.root).as_posix() if p.is_file() else None
|
|
584
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
def plans_scan(self):
|
|
588
|
+
from .a365_sync import is_a365
|
|
589
|
+
for plan_f, items in self.plans():
|
|
590
|
+
base, bom, key = plan_f.parent, self.plan_bom(plan_f), plan_f.parent.name
|
|
591
|
+
kdir = self.root / "kicad" / key
|
|
592
|
+
kex = {}
|
|
593
|
+
for kx in [kdir / "kicad_export.json"] if (kdir / "kicad_export.json").is_file() else []:
|
|
594
|
+
self._sig(kx)
|
|
595
|
+
try:
|
|
596
|
+
d = _parsed(kx, "json", lambda p: json.loads(Path(p).read_text(encoding="utf-8")))
|
|
597
|
+
lib = d.get("library") or "ecadlib"
|
|
598
|
+
for p in d.get("parts") or []:
|
|
599
|
+
kex[p["part"]] = (lib, p)
|
|
600
|
+
except Exception as e: # noqa: BLE001
|
|
601
|
+
self.errors.append(f"{self._show(kx)}: {type(e).__name__}: {e}")
|
|
602
|
+
for i, it in enumerate(items):
|
|
603
|
+
if not isinstance(it, dict) or not it.get("name"):
|
|
604
|
+
continue
|
|
605
|
+
par = {p.get("name"): p.get("value") for p in it.get("parameters") or [] if isinstance(p, dict)}
|
|
606
|
+
common = dict(mpn=_field(par, MPN_FIELDS), manufacturer=_field(par, MFR_FIELDS), value=_field(par, ("value",)),
|
|
607
|
+
description=it.get("description") or "", plan=self._show(plan_f), bom=bom, item=i,
|
|
608
|
+
status=it.get("status"))
|
|
609
|
+
pulled = is_a365(it)
|
|
610
|
+
origin = "library" if pulled else "ecadlib"
|
|
611
|
+
a = it.get("a365") or {}
|
|
612
|
+
if a:
|
|
613
|
+
self.copies.append(_copy("altium365", it["name"], origin, plan_f, **common, revision=a.get("revision"),
|
|
614
|
+
lifecycle=a.get("lifecycle"),
|
|
615
|
+
updated=iso(a.get("modified_at") or a.get("synced_at")),
|
|
616
|
+
updated_how="Altium 365 revision" if a.get("modified_at") else "synced",
|
|
617
|
+
symbol=(it.get("symbol") or {}).get("ref") or "",
|
|
618
|
+
footprint=(it.get("footprint") or {}).get("ref") or "",
|
|
619
|
+
where="Altium 365" + (f" ({a.get('workspace')})" if a.get("workspace") else ""),
|
|
620
|
+
files=[plan_f]))
|
|
621
|
+
sym, fp = it.get("symbol") or {}, it.get("footprint") or {}
|
|
622
|
+
sf = sym.get("file") and (Path(sym["file"]) if Path(sym["file"]).is_absolute() else base / sym["file"])
|
|
623
|
+
ff = fp.get("file") and (Path(fp["file"]) if Path(fp["file"]).is_absolute() else base / fp["file"])
|
|
624
|
+
files = [f for f in (sf, ff) if f and f.is_file()]
|
|
625
|
+
if files and it.get("status") != "duplicate":
|
|
626
|
+
pins = pads = None
|
|
627
|
+
prefix = ""
|
|
628
|
+
if sf and sf.is_file() and sf.suffix.lower() == ".schlib":
|
|
629
|
+
es = self._read(sf, "schlib", _schlib) or []
|
|
630
|
+
e = next((x for x in es if x["name"] == sym.get("ref")), es[0] if len(es) == 1 else None)
|
|
631
|
+
if e:
|
|
632
|
+
pins, prefix = e["pins"], e["prefix"]
|
|
633
|
+
if ff and ff.is_file() and ff.suffix.lower() == ".pcblib":
|
|
634
|
+
fps = self._read(ff, "pcblib", _pcblib) or {}
|
|
635
|
+
pads = fps.get(fp.get("ref")) if fp.get("ref") in fps else (next(iter(fps.values())) if len(fps) == 1 else None)
|
|
636
|
+
for f in files:
|
|
637
|
+
self._sig(f)
|
|
638
|
+
self.copies.append(_copy("altium", it["name"], origin, files[0], **common, symbol=sym.get("ref") or "",
|
|
639
|
+
footprint=fp.get("ref") or "", pins=pins, pads=pads, prefix=prefix,
|
|
640
|
+
where="ecadlib .SchLib / .PcbLib", files=files))
|
|
641
|
+
if it["name"] in kex:
|
|
642
|
+
lib, p = kex[it["name"]]
|
|
643
|
+
symf, modf = kdir / f"{lib}.kicad_sym", kdir / f"{lib}.pretty" / f"{p.get('footprint')}.kicad_mod"
|
|
644
|
+
files = [f for f in (symf, modf) if f.is_file()]
|
|
645
|
+
if files:
|
|
646
|
+
es = self._read(symf, "kicad_sym", _kicad_sym) if symf.is_file() else None
|
|
647
|
+
e = next((x for x in es or [] if x["name"] == p.get("symbol_name")), None)
|
|
648
|
+
pads = self._read(modf, "kicad_mod", _kicad_mod) if modf.is_file() else None
|
|
649
|
+
for f in files:
|
|
650
|
+
self._sig(f)
|
|
651
|
+
self.copies.append(_copy("kicad", it["name"], origin, files[0], **common,
|
|
652
|
+
symbol=f"{lib}:{p.get('symbol_name')}", footprint=f"{lib}:{p.get('footprint')}",
|
|
653
|
+
pins=e["pins"] if e else None, prefix=e["prefix"] if e else "", pads=pads,
|
|
654
|
+
where=f"ecadlib KiCad library ({lib})", files=files))
|
|
655
|
+
# pulled whole Altium 365 workspace (ecadlib a365 pull without a BOM)
|
|
656
|
+
wp = self.root / "altium365" / "workspace_plan.json"
|
|
657
|
+
if wp.is_file():
|
|
658
|
+
self._sig(wp)
|
|
659
|
+
try:
|
|
660
|
+
items = _parsed(wp, "plan", lambda p: json.loads(Path(p).read_text(encoding="utf-8")))
|
|
661
|
+
except Exception as e: # noqa: BLE001
|
|
662
|
+
items = []
|
|
663
|
+
self.errors.append(f"{self._show(wp)}: {type(e).__name__}: {e}")
|
|
664
|
+
for it in items if isinstance(items, list) else []:
|
|
665
|
+
a = it.get("a365") or {}
|
|
666
|
+
par = {p.get("name"): p.get("value") for p in it.get("parameters") or [] if isinstance(p, dict)}
|
|
667
|
+
self.copies.append(_copy("altium365", it.get("name") or "", "library", wp, mpn=_field(par, MPN_FIELDS),
|
|
668
|
+
manufacturer=_field(par, MFR_FIELDS), value=_field(par, ("value",)),
|
|
669
|
+
description=it.get("description") or "", revision=a.get("revision"),
|
|
670
|
+
lifecycle=a.get("lifecycle"), updated=iso(a.get("modified_at") or a.get("synced_at")),
|
|
671
|
+
updated_how="Altium 365 revision", symbol=(it.get("symbol") or {}).get("ref") or "",
|
|
672
|
+
footprint=(it.get("footprint") or {}).get("ref") or "",
|
|
673
|
+
where="Altium 365" + (f" ({a.get('workspace')})" if a.get("workspace") else ""),
|
|
674
|
+
files=[wp]))
|
|
675
|
+
# other tools' files ecadlib wrote into the workspace
|
|
676
|
+
for tool, r in _READERS.items():
|
|
677
|
+
for d in r["workspace_dirs"]:
|
|
678
|
+
for f in sorted((self.root / d).rglob("*")) if (self.root / d).is_dir() else []:
|
|
679
|
+
if f.suffix.lower() in r["exts"] and f.is_file():
|
|
680
|
+
self.tool_file(tool, r, f, "ecadlib")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# ------------------------------------------------------------------ comparing copies
|
|
684
|
+
def _value(v):
|
|
685
|
+
from .kicad_index import parse_value
|
|
686
|
+
try:
|
|
687
|
+
return parse_value(v, True)
|
|
688
|
+
except Exception: # noqa: BLE001
|
|
689
|
+
return None, None
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _fmt(v):
|
|
693
|
+
return f"{float(v):.2f}".rstrip("0").rstrip(".")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def pad_diff(a, b):
|
|
697
|
+
"""What differs between two pad lists, in words; '' when they match."""
|
|
698
|
+
if a == b:
|
|
699
|
+
return ""
|
|
700
|
+
if len(a) != len(b):
|
|
701
|
+
return f"{len(a)} pads, the other has {len(b)}"
|
|
702
|
+
by = {p[0]: p for p in b}
|
|
703
|
+
for p in a:
|
|
704
|
+
q = by.get(p[0])
|
|
705
|
+
if q is None:
|
|
706
|
+
return f"pad {p[0]} is not in the other copy"
|
|
707
|
+
if (p[3], p[4]) != (q[3], q[4]):
|
|
708
|
+
return f"pad {p[0]} is {_fmt(p[3])} x {_fmt(p[4])} mm, the other {_fmt(q[3])} x {_fmt(q[4])}"
|
|
709
|
+
if (p[1], p[2]) != (q[1], q[2]):
|
|
710
|
+
return f"pad {p[0]} is at ({_fmt(p[1])}, {_fmt(p[2])}), the other at ({_fmt(q[1])}, {_fmt(q[2])})"
|
|
711
|
+
if p[6] != q[6]:
|
|
712
|
+
return f"pad {p[0]} is {'through-hole' if p[6] else 'surface mount'} here, not in the other"
|
|
713
|
+
if p != q:
|
|
714
|
+
return f"pad {p[0]} is turned differently"
|
|
715
|
+
return "pads differ"
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _plain(name, num):
|
|
719
|
+
return "" if name in ("", num) else name
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def pin_diff(a, b):
|
|
723
|
+
"""Pins compared by number; a name counts only when both copies name the pin (KiCad's '~' and numbers as names
|
|
724
|
+
don't)."""
|
|
725
|
+
na, nb = {p[0]: _plain(p[1], p[0]) for p in a}, {p[0]: _plain(p[1], p[0]) for p in b}
|
|
726
|
+
if set(na) != set(nb):
|
|
727
|
+
extra, gone = sorted(set(na) - set(nb)), sorted(set(nb) - set(na))
|
|
728
|
+
bits = ([f"pin {', '.join(extra[:4])} only here"] if extra else []) + ([f"pin {', '.join(gone[:4])} only in the other"] if gone else [])
|
|
729
|
+
return f"{len(na)} pins, the other has {len(nb)}" if len(na) != len(nb) else "; ".join(bits)
|
|
730
|
+
for k in sorted(na):
|
|
731
|
+
if na[k] and nb[k] and na[k] != nb[k]:
|
|
732
|
+
return f"pin {k} is {na[k]} here, {nb[k]} in the other"
|
|
733
|
+
return ""
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def compare(copies, stale_days):
|
|
737
|
+
"""Set state / diffs on each copy against the newest copy that has the same kind of data; -> {tool: cell}."""
|
|
738
|
+
def newest(pred):
|
|
739
|
+
cs = [c for c in copies if pred(c)]
|
|
740
|
+
return max(cs, key=lambda c: c["updated"] or "") if cs else None
|
|
741
|
+
ref_pads = newest(lambda c: c["pads"])
|
|
742
|
+
ref_pins = newest(lambda c: c["pins"])
|
|
743
|
+
ref_val = newest(lambda c: _value(c["value"])[0] is not None)
|
|
744
|
+
latest = max((c["updated"] for c in copies if c["updated"]), default=None)
|
|
745
|
+
for c in copies:
|
|
746
|
+
diffs, compared = [], False
|
|
747
|
+
if c["pads"] and ref_pads:
|
|
748
|
+
compared = True
|
|
749
|
+
d = pad_diff(c["pads"], ref_pads["pads"]) if c is not ref_pads else ""
|
|
750
|
+
if d:
|
|
751
|
+
diffs.append({"what": "footprint", "text": d, "against": ref_pads["tool"]})
|
|
752
|
+
if c["pins"] and ref_pins:
|
|
753
|
+
compared = True
|
|
754
|
+
d = pin_diff(c["pins"], ref_pins["pins"]) if c is not ref_pins else ""
|
|
755
|
+
if d:
|
|
756
|
+
diffs.append({"what": "symbol", "text": d, "against": ref_pins["tool"]})
|
|
757
|
+
v, u = _value(c["value"])
|
|
758
|
+
if v is not None and ref_val and c is not ref_val:
|
|
759
|
+
rv, ru = _value(ref_val["value"])
|
|
760
|
+
compared = True
|
|
761
|
+
if abs(v - rv) > 1e-9 * max(abs(v), abs(rv), 1e-30) or (u and ru and u != ru):
|
|
762
|
+
diffs.append({"what": "value", "text": f"value {c['value']}, the other {ref_val['value']}", "against": ref_val["tool"]})
|
|
763
|
+
elif v is not None:
|
|
764
|
+
compared = True
|
|
765
|
+
c["diffs"] = diffs
|
|
766
|
+
behind = _days(c["updated"], latest) if c["updated"] and latest else 0.0
|
|
767
|
+
if diffs and behind >= stale_days:
|
|
768
|
+
ahead = max((x for x in copies if x["updated"] == latest), key=lambda x: x["tool"] == diffs[0]["against"])
|
|
769
|
+
c["state"] = "behind"
|
|
770
|
+
c["behind"] = {"days": round(behind, 1), "words": age_words(behind), "tool": ahead["tool"]}
|
|
771
|
+
else:
|
|
772
|
+
c["state"] = "different" if diffs else ("same" if compared else "present")
|
|
773
|
+
cells = {}
|
|
774
|
+
for c in copies:
|
|
775
|
+
cell = cells.setdefault(c["tool"], {"state": "same", "copies": 0, "updated": None, "updated_by": None, "note": ""})
|
|
776
|
+
cell["copies"] += 1
|
|
777
|
+
if RANK[c["state"]] > RANK[cell["state"]] or (cell["copies"] == 1):
|
|
778
|
+
cell["state"] = c["state"]
|
|
779
|
+
if c["state"] == "behind":
|
|
780
|
+
cell["note"] = f"{age_words(c['behind']['days'])} behind {LABEL.get(c['behind']['tool'], c['behind']['tool'])}"
|
|
781
|
+
elif c["diffs"]:
|
|
782
|
+
cell["note"] = f"{c['diffs'][0]['what']}: {c['diffs'][0]['text']}"
|
|
783
|
+
elif c["state"] == "present":
|
|
784
|
+
cell["note"] = "nothing to compare it with"
|
|
785
|
+
if (c["updated"] or "") > (cell["updated"] or ""):
|
|
786
|
+
cell["updated"], cell["updated_by"] = c["updated"], c["updated_by"]
|
|
787
|
+
for tool, cell in cells.items():
|
|
788
|
+
origins = [c["origin"] for c in copies if c["tool"] == tool]
|
|
789
|
+
cell["duplicate"] = origins.count("library") > 1 or origins.count("ecadlib") > 1
|
|
790
|
+
if cell["duplicate"]:
|
|
791
|
+
cell["note"] = (cell["note"] + "; " if cell["note"] else "") + f"{cell['copies']} copies in this tool"
|
|
792
|
+
elif cell["copies"] > 1:
|
|
793
|
+
cell["note"] = (cell["note"] + "; " if cell["note"] else "") + "ecadlib's copy and yours"
|
|
794
|
+
return cells
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# ------------------------------------------------------------------ history
|
|
798
|
+
def hub_dir(root):
|
|
799
|
+
d = Path(root) / "hub"
|
|
800
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
801
|
+
return d
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def record(root, entries):
|
|
805
|
+
"""Append history entries ({when, who, what, key?, name?, tool?, bom?, file?, detail?})."""
|
|
806
|
+
if not entries:
|
|
807
|
+
return
|
|
808
|
+
f = hub_dir(root) / HISTORY
|
|
809
|
+
with _LOCK:
|
|
810
|
+
with open(f, "a", encoding="utf-8") as fh:
|
|
811
|
+
for e in entries:
|
|
812
|
+
fh.write(json.dumps({k: v for k, v in e.items() if v not in (None, "")}, sort_keys=True) + "\n")
|
|
813
|
+
if f.stat().st_size > HISTORY_MAX:
|
|
814
|
+
lines = f.read_text(encoding="utf-8").splitlines()
|
|
815
|
+
write_text_atomic(f, "\n".join(lines[len(lines) // 2:]) + "\n")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def read_history(root):
|
|
819
|
+
f = Path(root) / "hub" / HISTORY
|
|
820
|
+
out = []
|
|
821
|
+
if f.is_file():
|
|
822
|
+
for line in f.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
823
|
+
with contextlib.suppress(ValueError):
|
|
824
|
+
e = json.loads(line)
|
|
825
|
+
if isinstance(e, dict):
|
|
826
|
+
out.append(e)
|
|
827
|
+
return out
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
ROUTE_WORDS = {
|
|
831
|
+
"/api/librarian/run": "filled the library", "/api/resolve/choose": "picked a symbol or footprint",
|
|
832
|
+
"/api/release-check": "ran the release check", "/api/altium365/push": "sent parts to Altium 365",
|
|
833
|
+
"/api/export/dblib": "exported the Altium DbLib", "/api/altium/models": "built .PcbLib / .SchLib files",
|
|
834
|
+
"/api/dedupe": "merged duplicates", "/api/agent/queue/approve": "approved a part in the review queue",
|
|
835
|
+
"/api/agent/queue/rebuild": "asked for a rebuild", "/api/demo": "built the sample library",
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def note_action(root, route, body, who, result=None):
|
|
840
|
+
"""A web-app action that changes library files, so the next scan can say who changed them."""
|
|
841
|
+
what = ROUTE_WORDS.get(route)
|
|
842
|
+
if not what or (route == "/api/altium365/push" and body.get("dry_run", True) is not False) or \
|
|
843
|
+
(route == "/api/dedupe" and body.get("merge") is not True):
|
|
844
|
+
return
|
|
845
|
+
e = {"when": now(), "who": who, "what": what, "kind": "action", "bom": body.get("bom") if isinstance(body.get("bom"), str) else None,
|
|
846
|
+
"name": body.get("part") if isinstance(body.get("part"), str) else None}
|
|
847
|
+
if route == "/api/altium365/push" and isinstance(result, dict):
|
|
848
|
+
e["detail"] = ", ".join(str(r.get("name")) for r in (result.get("results") or [])
|
|
849
|
+
if isinstance(r, dict) and str(r.get("status", "")).startswith(("released", "updated")))[:500]
|
|
850
|
+
record(root, [e])
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _attribute(copy, actions):
|
|
854
|
+
"""Who changed a copy: the newest web-app action on its BOM or part within 20 minutes before its date."""
|
|
855
|
+
if not copy["updated"]:
|
|
856
|
+
return None
|
|
857
|
+
best = None
|
|
858
|
+
for a in actions:
|
|
859
|
+
if not (a.get("bom") and a["bom"] == copy.get("bom")) and not (a.get("name") and a["name"] == copy["name"]):
|
|
860
|
+
continue
|
|
861
|
+
gap = _days(a["when"], copy["updated"]) * 86400
|
|
862
|
+
if -120 <= gap <= 1200 and (best is None or a["when"] > best["when"]):
|
|
863
|
+
best = a
|
|
864
|
+
return best
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _copy_id(c):
|
|
868
|
+
return f"{c['tool']}|{c.get('where') or ''}|{c['path']}|{c['name']}"
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _fingerprint(c):
|
|
872
|
+
return hashlib.sha256(json.dumps([c["pads"], c["pins"], c["value"], c["revision"], c["symbol"], c["footprint"]],
|
|
873
|
+
sort_keys=True, default=list).encode()).hexdigest()[:16]
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _h(x):
|
|
877
|
+
return hashlib.sha256(json.dumps(x, default=list).encode()).hexdigest()[:12] if x is not None else None
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _what_changed(a, b):
|
|
881
|
+
"""In words, what changed between two scans of one copy."""
|
|
882
|
+
out = []
|
|
883
|
+
if a.get("pads") != b["pads"]:
|
|
884
|
+
out.append("footprint pads" if a.get("pads") and b["pads"] else ("footprint added" if b["pads"] else "footprint gone"))
|
|
885
|
+
if a.get("pins") != b["pins"]:
|
|
886
|
+
out.append("symbol pins" if a.get("pins") and b["pins"] else ("symbol added" if b["pins"] else "symbol gone"))
|
|
887
|
+
if a.get("value") != b["value"]:
|
|
888
|
+
out.append(f"value {a.get('value') or '(none)'} -> {b['value'] or '(none)'}")
|
|
889
|
+
if a.get("rev") != b["rev"]:
|
|
890
|
+
out.append(f"revision {a.get('rev') or '(none)'} -> {b['rev'] or '(none)'}")
|
|
891
|
+
if a.get("sym") != b["sym"] or a.get("fpname") != b["fpname"]:
|
|
892
|
+
out.append("symbol or footprint link")
|
|
893
|
+
return ", ".join(out) or None
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _diff_state(root, copies, show, sources):
|
|
897
|
+
"""Compare this scan with the last one: added / changed / removed copies become history entries. A copy from a
|
|
898
|
+
library that was not read this time (another --lib) is not "removed": its entry is kept for when it is back."""
|
|
899
|
+
f = hub_dir(root) / STATE
|
|
900
|
+
old = {}
|
|
901
|
+
with contextlib.suppress(OSError, ValueError):
|
|
902
|
+
old = json.loads(f.read_text(encoding="utf-8")).get("copies") or {}
|
|
903
|
+
first = not f.is_file()
|
|
904
|
+
cur = {_copy_id(c): {"fp": _fingerprint(c), "key": c["key"], "name": c["name"], "tool": c["tool"], "file": show(c["path"]),
|
|
905
|
+
"updated": c["updated"], "who": c["updated_by"], "pads": _h(c["pads"]), "pins": _h(c["pins"]),
|
|
906
|
+
"value": c["value"], "rev": c["revision"], "sym": c["symbol"], "fpname": c["footprint"],
|
|
907
|
+
"src": c["src"]} for c in copies}
|
|
908
|
+
for k, v in old.items():
|
|
909
|
+
if k not in cur and v.get("src", "workspace") not in sources:
|
|
910
|
+
cur[k] = v # its library was not read this time
|
|
911
|
+
events = []
|
|
912
|
+
if not first:
|
|
913
|
+
added = [(k, v) for k, v in cur.items() if k not in old]
|
|
914
|
+
removed = [(k, v) for k, v in old.items() if k not in cur]
|
|
915
|
+
changed = [(k, v) for k, v in cur.items() if k in old and old[k]["fp"] != v["fp"]]
|
|
916
|
+
t = now()
|
|
917
|
+
for kind, rows in (("added", added), ("removed", removed)):
|
|
918
|
+
by_file = {}
|
|
919
|
+
for k, v in rows:
|
|
920
|
+
by_file.setdefault((v["tool"], v["file"]), []).append(v)
|
|
921
|
+
for (tool, file), vs in by_file.items():
|
|
922
|
+
if len(vs) > BULK:
|
|
923
|
+
events.append({"when": t, "what": f"{len(vs)} parts {kind}", "tool": tool, "file": file, "kind": "scan",
|
|
924
|
+
"who": vs[0]["who"] if kind == "added" else None})
|
|
925
|
+
continue
|
|
926
|
+
for v in vs:
|
|
927
|
+
events.append({"when": (v["updated"] or t) if kind == "added" else t, "what": kind, "key": v["key"],
|
|
928
|
+
"name": v["name"], "tool": tool, "file": file, "who": v["who"] if kind == "added" else None,
|
|
929
|
+
"kind": "scan"})
|
|
930
|
+
for k, v in changed:
|
|
931
|
+
events.append({"when": v["updated"] or t, "what": "changed", "key": v["key"], "name": v["name"], "tool": v["tool"],
|
|
932
|
+
"file": v["file"], "who": v["who"], "kind": "scan", "detail": _what_changed(old[k], v)})
|
|
933
|
+
if first or events or set(cur) != set(old) or any(old[k]["fp"] != cur[k]["fp"] for k in cur if k in old):
|
|
934
|
+
write_text_atomic(f, json.dumps({"scanned": now(), "copies": cur}, sort_keys=True))
|
|
935
|
+
record(root, events)
|
|
936
|
+
return events
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
# ------------------------------------------------------------------ where-used and lifecycle
|
|
940
|
+
_WU = {}
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _where_used(projects):
|
|
944
|
+
"""{mpn key or NAME: [{project, path, designators, qty}]} from Altium projects (altium_project.where_used)."""
|
|
945
|
+
if not projects:
|
|
946
|
+
return {}, []
|
|
947
|
+
from . import altium_project as AP
|
|
948
|
+
files = AP.find_projects([Path(p).expanduser() for p in projects])
|
|
949
|
+
files += [f for d in projects for f in (sorted(Path(d).expanduser().rglob("*.kicad_sch")) if Path(d).expanduser().is_dir() else [])]
|
|
950
|
+
sig = tuple((str(f), _stat(f)) for f in files)
|
|
951
|
+
with _LOCK:
|
|
952
|
+
if _WU.get("sig") == sig:
|
|
953
|
+
return _WU["val"]
|
|
954
|
+
res = AP.where_used([Path(p).expanduser() for p in projects])
|
|
955
|
+
out = _kicad_where_used(projects)
|
|
956
|
+
for e in res["parts"].values():
|
|
957
|
+
uses = [{"project": u["project"], "path": str(Path(u["path"]).resolve()), "qty": u["qty"], "designators": u["designators"][:40]} for u in e["uses"]]
|
|
958
|
+
keys = ({mpn_key(e["mpn"])} if e["mpn"] else set()) | {"name:" + str(x).upper() for x in e["design_item_ids"] | e["librefs"]}
|
|
959
|
+
for k in keys - {""}:
|
|
960
|
+
out.setdefault(k, []).extend(uses)
|
|
961
|
+
val = (out, res["warnings"][:20])
|
|
962
|
+
with _LOCK:
|
|
963
|
+
_WU.update(sig=sig, val=val)
|
|
964
|
+
return val
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _kicad_where_used(projects):
|
|
968
|
+
"""{mpn key or NAME: [uses]} from the schematics of KiCad projects (.kicad_pro) under the project folders."""
|
|
969
|
+
from . import sexpr
|
|
970
|
+
from .hub_copy import projects as find
|
|
971
|
+
out = {}
|
|
972
|
+
for prj in [p for p in find(projects) if p["tool"] == "kicad"]:
|
|
973
|
+
parts = {}
|
|
974
|
+
for sch in sorted(Path(prj["path"]).parent.glob("*.kicad_sch")):
|
|
975
|
+
try:
|
|
976
|
+
tree = _parsed(sch, "kicad_sch", lambda f: sexpr.parse(Path(f).read_text(encoding="utf-8", errors="replace")))
|
|
977
|
+
except Exception: # noqa: BLE001 - a schematic that doesn't parse is skipped
|
|
978
|
+
continue
|
|
979
|
+
for sym in [n for n in tree[1:] if isinstance(n, list) and n and n[0] == "symbol"]:
|
|
980
|
+
lib_id = (sexpr.first(sym, "lib_id") or [None, ""])[1]
|
|
981
|
+
props = {str(p[1]): str(p[2]) for p in sexpr.find(sym, "property") if len(p) > 2}
|
|
982
|
+
ref = props.get("Reference", "")
|
|
983
|
+
if not lib_id or ref.startswith("#"):
|
|
984
|
+
continue
|
|
985
|
+
mpn = _field(props, MPN_FIELDS)
|
|
986
|
+
keys = ({mpn_key(mpn)} if mpn else set()) | {"name:" + lib_id.split(":")[-1].upper()}
|
|
987
|
+
for k in keys - {""}:
|
|
988
|
+
u = parts.setdefault(k, {"project": prj["name"], "path": prj["path"], "qty": 0, "designators": []})
|
|
989
|
+
u["qty"] += 1
|
|
990
|
+
if len(u["designators"]) < 40:
|
|
991
|
+
u["designators"].append(ref)
|
|
992
|
+
for k, u in parts.items():
|
|
993
|
+
out.setdefault(k, []).append(u)
|
|
994
|
+
return out
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _lifecycle(root):
|
|
998
|
+
"""{mpn key: {status, label, since, checked}} from upkeep's lifecycle state files (the worst status per part)."""
|
|
999
|
+
from . import lifecycle as L
|
|
1000
|
+
out = {}
|
|
1001
|
+
for f in sorted(Path(root).glob("upkeep/*/state/lifecycle_state.json")):
|
|
1002
|
+
with contextlib.suppress(OSError, ValueError):
|
|
1003
|
+
for v in (json.loads(f.read_text(encoding="utf-8")).get("parts") or {}).values():
|
|
1004
|
+
k = mpn_key(v.get("mpn"))
|
|
1005
|
+
if k and L.SEVERITY.get(v.get("status"), -1) >= L.SEVERITY.get((out.get(k) or {}).get("status"), -1):
|
|
1006
|
+
out[k] = {"status": v.get("status"), "label": L.LABEL.get(v.get("status"), v.get("status")),
|
|
1007
|
+
"since": v.get("since"), "checked": v.get("checked"), "from": "upkeep lifecycle check"}
|
|
1008
|
+
return out
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _lifecycle_word(raw):
|
|
1012
|
+
from . import lifecycle as L
|
|
1013
|
+
st = L.normalise(raw) if raw else "unknown"
|
|
1014
|
+
return {"status": st, "label": L.LABEL.get(st, raw), "from": "Altium 365", "raw": raw}
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
# ------------------------------------------------------------------ the hub
|
|
1018
|
+
def settings(ws):
|
|
1019
|
+
sec = dict((ws.rules().get("hub") or {}))
|
|
1020
|
+
rf = ws.rules_in_use()
|
|
1021
|
+
here = lambda p: str(Path(p).expanduser() if Path(p).expanduser().is_absolute() or not rf else Path(rf).parent / p)
|
|
1022
|
+
libs = [*ws.libs, *[here(p) for p in sec.get("libraries") or []]]
|
|
1023
|
+
return {"libraries": list(dict.fromkeys(libs)), "projects": [here(p) for p in sec.get("projects") or []],
|
|
1024
|
+
"stale_days": int(sec.get("stale_days") or 14)}
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
_BUILD_LOCK = threading.Lock()
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def build(ws, force=False):
|
|
1031
|
+
"""Scan everything -> {rows, tools, summary, errors, ...}; reuses the last result while no file changed. One build
|
|
1032
|
+
at a time, so two page loads can't both record the same change."""
|
|
1033
|
+
with _BUILD_LOCK:
|
|
1034
|
+
return _build(ws, force)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _build(ws, force):
|
|
1038
|
+
_builtin_readers()
|
|
1039
|
+
_plugin_readers()
|
|
1040
|
+
cfg = settings(ws)
|
|
1041
|
+
root = Path(ws.root).resolve()
|
|
1042
|
+
sc = Scan(ws, cfg["libraries"])
|
|
1043
|
+
sc.libraries_scan()
|
|
1044
|
+
sc.plans_scan()
|
|
1045
|
+
wu, wu_warn = _where_used(cfg["projects"]) # cached on its own files; a changed schematic rebuilds the hub too
|
|
1046
|
+
with _LOCK:
|
|
1047
|
+
wu_sig = _WU.get("sig")
|
|
1048
|
+
sig = hashlib.sha256(json.dumps([sorted(sc.sigs), cfg, sorted(_READERS), wu_sig], default=str).encode()).hexdigest()
|
|
1049
|
+
with _LOCK:
|
|
1050
|
+
last = _BUILT.get(str(root))
|
|
1051
|
+
if last and last[0] == sig and not force:
|
|
1052
|
+
return last[1]
|
|
1053
|
+
actions = [e for e in read_history(root) if e.get("kind") == "action"]
|
|
1054
|
+
budget = [300]
|
|
1055
|
+
for c in sc.copies:
|
|
1056
|
+
c["key"] = part_key(c["mpn"], c["name"])
|
|
1057
|
+
files = [Path(f) for f in c.get("files") or ([c["path"]] if c["path"] else [])]
|
|
1058
|
+
c["files"] = [str(f) for f in files]
|
|
1059
|
+
if c["updated"] is None: # newest file of the copy: its git commit when clean, else its date
|
|
1060
|
+
dated = []
|
|
1061
|
+
for f in files:
|
|
1062
|
+
g = _git_last(f, budget)
|
|
1063
|
+
st = _stat(f)
|
|
1064
|
+
if g and g[1]:
|
|
1065
|
+
dated.append((g[1], g[0], "git commit"))
|
|
1066
|
+
elif st:
|
|
1067
|
+
dated.append((iso(st[0] / 1e9), None, "file saved"))
|
|
1068
|
+
if dated:
|
|
1069
|
+
c["updated"], c["updated_by"], c["updated_how"] = max(dated, key=lambda x: x[0])
|
|
1070
|
+
if not c["updated_by"]:
|
|
1071
|
+
a = _attribute(c, actions)
|
|
1072
|
+
if a and a.get("who"):
|
|
1073
|
+
c["updated_by"], c["updated_how"] = a["who"], f"{c['updated_how'] or 'changed'}: {a['what']} in the web app"
|
|
1074
|
+
for c in sc.copies:
|
|
1075
|
+
c["src"] = sc.src.get(str(c["path"]), "workspace") if c["origin"] == "library" and c["plan"] is None else "workspace"
|
|
1076
|
+
_diff_state(root, sc.copies, sc._show, {"workspace", *[str(p) for p in sc.libraries]})
|
|
1077
|
+
life = _lifecycle(root)
|
|
1078
|
+
rows = {}
|
|
1079
|
+
for c in sc.copies:
|
|
1080
|
+
rows.setdefault(c["key"], []).append(c)
|
|
1081
|
+
out = []
|
|
1082
|
+
names = {k: next((c["name"] for c in cs if c["origin"] == "ecadlib"), cs[0]["name"]) for k, cs in rows.items()}
|
|
1083
|
+
for k, cs in rows.items():
|
|
1084
|
+
cells = compare(cs, cfg["stale_days"])
|
|
1085
|
+
plan_c = next((c for c in cs if c["plan"] and c["status"]), None)
|
|
1086
|
+
lc = life.get(k) or next((_lifecycle_word(c["lifecycle"]) for c in cs if c["lifecycle"]), None)
|
|
1087
|
+
uses = wu.get(k, []) + [u for c in cs for u in wu.get("name:" + c["name"].upper(), [])]
|
|
1088
|
+
uses = list({(u["path"], tuple(u["designators"])): u for u in uses}.values())
|
|
1089
|
+
latest = max(cs, key=lambda c: c["updated"] or "")
|
|
1090
|
+
origin = {c["origin"] for c in cs}
|
|
1091
|
+
flags = sorted({c["state"] for c in cs if c["state"] in ("different", "behind")})
|
|
1092
|
+
if plan_c and plan_c["status"] in ("held", "awaiting_approval"):
|
|
1093
|
+
flags.append(plan_c["status"])
|
|
1094
|
+
if lc and lc["status"] in ("nrnd", "ltb", "obsolete"):
|
|
1095
|
+
flags.append("eol")
|
|
1096
|
+
if any(v["duplicate"] for v in cells.values()):
|
|
1097
|
+
flags.append("duplicate")
|
|
1098
|
+
places = {}
|
|
1099
|
+
for c in sorted(cs, key=lambda c: c["updated"] or "", reverse=True):
|
|
1100
|
+
lib = sc._show(c["path"]) if c["path"] else (c["where"] or LABEL.get(c["tool"], c["tool"]))
|
|
1101
|
+
places.setdefault(c["tool"], []).append({"library": lib, "origin": c["origin"], "symbol": c["symbol"],
|
|
1102
|
+
"footprint": c["footprint"], "updated": c["updated"],
|
|
1103
|
+
"updated_by": c["updated_by"]})
|
|
1104
|
+
out.append({"key": k, "name": names[k], "places": places, "mpn": next((c["mpn"] for c in cs if c["mpn"]), ""),
|
|
1105
|
+
"manufacturer": next((c["manufacturer"] for c in cs if c["manufacturer"]), ""),
|
|
1106
|
+
"description": next((c["description"] for c in cs if c["description"]), ""),
|
|
1107
|
+
"origin": "both" if len(origin) > 1 else origin.pop(), "status": plan_c["status"] if plan_c else None,
|
|
1108
|
+
"cells": cells, "updated": latest["updated"], "updated_by": latest["updated_by"],
|
|
1109
|
+
"updated_tool": latest["tool"], "lifecycle": lc, "used_in": len({u["path"] for u in uses}),
|
|
1110
|
+
"projects": sorted({u["path"] for u in uses}),
|
|
1111
|
+
"flags": flags})
|
|
1112
|
+
out.sort(key=lambda r: (r["name"].upper(), r["key"]))
|
|
1113
|
+
tools = [{"tool": t, "label": lab, "reader": t in ("altium", "altium365", "kicad") or t in _READERS,
|
|
1114
|
+
"parts": sum(t in r["cells"] for r in out), "missing": sum(t not in r["cells"] for r in out),
|
|
1115
|
+
**_tool_summary([p for r in out for p in r["places"].get(t, [])]),
|
|
1116
|
+
"different": sum(r["cells"].get(t, {}).get("state") == "different" for r in out),
|
|
1117
|
+
"behind": sum(r["cells"].get(t, {}).get("state") == "behind" for r in out)} for t, lab in TOOLS]
|
|
1118
|
+
from .hub_copy import TARGETS, source_for
|
|
1119
|
+
missing_in = {r["key"]: r["cells"] for r in out}
|
|
1120
|
+
for tl in tools: # parts other tools have that the hub can convert into this one
|
|
1121
|
+
tl["can_copy"] = sum(1 for k, cs in rows.items() if tl["tool"] in TARGETS and tl["tool"] not in missing_in[k]
|
|
1122
|
+
and source_for(cs, tl["tool"])) if tl["tool"] in TARGETS else 0
|
|
1123
|
+
summary = {"parts": len(out), "ecadlib": sum(r["origin"] in ("ecadlib", "both") for r in out),
|
|
1124
|
+
"library": sum(r["origin"] in ("library", "both") for r in out),
|
|
1125
|
+
"different": sum("different" in r["flags"] for r in out), "behind": sum("behind" in r["flags"] for r in out),
|
|
1126
|
+
"held": sum(r["status"] in ("held", "awaiting_approval") for r in out), "eol": sum("eol" in r["flags"] for r in out),
|
|
1127
|
+
"duplicates": sum("duplicate" in r["flags"] for r in out),
|
|
1128
|
+
"updated": max((r["updated"] for r in out if r["updated"]), default=None), "scanned": now()}
|
|
1129
|
+
res = {"rows": out, "tools": tools, "summary": summary, "errors": sc.errors[:50], "warnings": (sc.warnings + wu_warn)[:50],
|
|
1130
|
+
"libraries": [sc._show(p) for p in cfg["libraries"]], "projects": cfg["projects"], "stale_days": cfg["stale_days"],
|
|
1131
|
+
"project_list": _project_list(cfg["projects"], out, rows),
|
|
1132
|
+
"_copies": sc.copies, "_show": sc._show}
|
|
1133
|
+
with _LOCK:
|
|
1134
|
+
_BUILT[str(root)] = (sig, res)
|
|
1135
|
+
return res
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _project_list(dirs, rows, copies):
|
|
1139
|
+
"""The projects under [hub] projects: how many library parts each uses, how many of those its tool lacks, and how
|
|
1140
|
+
many of those the hub can copy into it."""
|
|
1141
|
+
from .hub_copy import projects as find, source_for
|
|
1142
|
+
out = []
|
|
1143
|
+
for p in find(dirs):
|
|
1144
|
+
used = [r for r in rows if p["path"] in r["projects"]]
|
|
1145
|
+
lack = [r for r in used if p["tool"] not in r["cells"]]
|
|
1146
|
+
out.append({**p, "parts": len(used), "missing": len(lack),
|
|
1147
|
+
"can_copy": sum(1 for r in lack if source_for(copies.get(r["key"], []), p["tool"]))})
|
|
1148
|
+
return sorted(out, key=lambda p: p["name"].upper())
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _tool_summary(places):
|
|
1152
|
+
"""One tool's libraries (file or folder, part count, newest change and by whom) and its newest change overall."""
|
|
1153
|
+
libs = {}
|
|
1154
|
+
for p in places:
|
|
1155
|
+
lib = libs.setdefault(p["library"], {"library": p["library"], "parts": 0, "updated": None, "updated_by": None,
|
|
1156
|
+
"origin": p["origin"]})
|
|
1157
|
+
lib["parts"] += 1
|
|
1158
|
+
if p["origin"] != lib["origin"]:
|
|
1159
|
+
lib["origin"] = "both"
|
|
1160
|
+
if (p["updated"] or "") > (lib["updated"] or ""):
|
|
1161
|
+
lib["updated"], lib["updated_by"] = p["updated"], p["updated_by"]
|
|
1162
|
+
libs = sorted(libs.values(), key=lambda l: (-l["parts"], l["library"]))
|
|
1163
|
+
newest = max(libs, key=lambda l: l["updated"] or "", default=None)
|
|
1164
|
+
return {"libraries": libs, "updated": newest["updated"] if newest else None,
|
|
1165
|
+
"updated_by": newest["updated_by"] if newest else None}
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _matches(r, q, have, lack, states, origin, flags):
|
|
1169
|
+
if q:
|
|
1170
|
+
hay = " ".join(str(r.get(k) or "") for k in ("name", "mpn", "manufacturer", "description")).lower()
|
|
1171
|
+
if not all(w in hay for w in q.lower().split()):
|
|
1172
|
+
return False
|
|
1173
|
+
if any(t not in r["cells"] for t in have) or any(t in r["cells"] for t in lack):
|
|
1174
|
+
return False
|
|
1175
|
+
if states and not any(c["state"] in states for c in r["cells"].values()):
|
|
1176
|
+
return False
|
|
1177
|
+
if origin and r["origin"] not in (origin, "both"):
|
|
1178
|
+
return False
|
|
1179
|
+
return not flags or any(f in r["flags"] for f in flags)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def listing(ws, q="", have=(), lack=(), states=(), origin="", flags=(), sort="name", page=1, size=100, force=False,
|
|
1183
|
+
tool="", library="", project=""):
|
|
1184
|
+
"""A page of hub rows, filtered: q (words in name / MPN / maker / description), have / lack (tools), states (cell
|
|
1185
|
+
states), origin (ecadlib / library), flags (different, behind, held, awaiting_approval, eol, duplicate).
|
|
1186
|
+
tool: one tool's library only (its copies, newest change in that tool first when sorted by update); library: one
|
|
1187
|
+
file or folder of that tool."""
|
|
1188
|
+
res = build(ws, force)
|
|
1189
|
+
have = (*have, tool) if tool else have
|
|
1190
|
+
rows = [r for r in res["rows"] if _matches(r, q, have, lack, states, origin, flags)
|
|
1191
|
+
and (not library or any(p["library"] == library for p in r["places"].get(tool, [])))
|
|
1192
|
+
and (not project or project in r["projects"])]
|
|
1193
|
+
if sort == "updated":
|
|
1194
|
+
rows.sort(key=lambda r: (r["cells"][tool]["updated"] if tool else r["updated"]) or "", reverse=True)
|
|
1195
|
+
elif sort == "attention":
|
|
1196
|
+
rows.sort(key=lambda r: (-max([RANK[c["state"]] for c in r["cells"].values()] + [0]) - 2 * ("held" in r["flags"]), r["name"].upper()))
|
|
1197
|
+
size = max(1, min(int(size or 100), 500))
|
|
1198
|
+
page = max(1, int(page or 1))
|
|
1199
|
+
return {"rows": rows[(page - 1) * size: page * size], "total": len(res["rows"]), "matched": len(rows), "page": page,
|
|
1200
|
+
"size": size, **{k: v for k, v in res.items() if k not in ("rows",) and not k.startswith("_")}}
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
# ------------------------------------------------------------------ one part
|
|
1204
|
+
def _public(c, show):
|
|
1205
|
+
keep = ("tool", "name", "origin", "mpn", "manufacturer", "value", "description", "symbol", "footprint", "updated",
|
|
1206
|
+
"updated_by", "updated_how", "revision", "plan", "bom", "status", "state", "diffs", "behind", "where", "lifecycle")
|
|
1207
|
+
d = {k: c.get(k) for k in keep}
|
|
1208
|
+
d["file"] = show(c["path"]) if c["path"] else None
|
|
1209
|
+
d["tool_label"] = LABEL.get(c["tool"], c["tool"])
|
|
1210
|
+
d["pads"] = len(c["pads"]) if c["pads"] is not None else None
|
|
1211
|
+
d["pins"] = len(c["pins"]) if c["pins"] is not None else None
|
|
1212
|
+
return d
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def footprint_picture(pads, scale=14):
|
|
1216
|
+
"""Altium-style top view of a pad list (y up, as stored) via app.footprint_svg."""
|
|
1217
|
+
from .app import footprint_svg
|
|
1218
|
+
if not pads:
|
|
1219
|
+
return None
|
|
1220
|
+
ps = [{"num": p[0], "x": p[1], "y": -p[2], "w": p[3], "h": p[4]} for p in pads]
|
|
1221
|
+
xs = [p["x"] - p["w"] / 2 for p in ps] + [p["x"] + p["w"] / 2 for p in ps]
|
|
1222
|
+
ys = [p["y"] - p["h"] / 2 for p in ps] + [p["y"] + p["h"] / 2 for p in ps]
|
|
1223
|
+
return footprint_svg({"pads": ps, "courtyard_bbox": (min(xs) - .25, min(ys) - .25, max(xs) + .25, max(ys) + .25)}, scale)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def symbol_picture(pins, prefix="", name=""):
|
|
1227
|
+
"""A plain box symbol: pins down both sides, number outside, name inside. A sketch of the pinout, not the drawing."""
|
|
1228
|
+
if not pins:
|
|
1229
|
+
return None
|
|
1230
|
+
import html as H
|
|
1231
|
+
pins = sorted(pins, key=lambda p: (len(p[0]), p[0]))
|
|
1232
|
+
half = (len(pins) + 1) // 2
|
|
1233
|
+
left, right = pins[:half], pins[half:][::-1]
|
|
1234
|
+
longest = max(len(p[1] or "") for p in pins) if pins else 0
|
|
1235
|
+
w = max(60, 12 + longest * 7 * 2 + 20)
|
|
1236
|
+
h = max(len(left), len(right), 1) * 20 + 20
|
|
1237
|
+
out = [f'<svg xmlns="http://www.w3.org/2000/svg" class="sym" viewBox="-40 -24 {w + 80} {h + 36}" width="{w + 80}" height="{h + 36}">',
|
|
1238
|
+
f'<text class="ref" x="0" y="-8">{H.escape((prefix or "U") + "?")}</text>',
|
|
1239
|
+
f'<text class="ref" x="{w}" y="-8" text-anchor="end">{H.escape(name[:40])}</text>',
|
|
1240
|
+
f'<rect class="body" x="0" y="0" width="{w}" height="{h}"/>']
|
|
1241
|
+
for side, ps in (("l", left), ("r", right)):
|
|
1242
|
+
for i, (num, nm) in enumerate(ps):
|
|
1243
|
+
y = 20 + i * 20
|
|
1244
|
+
x0, x1 = (-30, 0) if side == "l" else (w, w + 30)
|
|
1245
|
+
out.append(f'<line class="pin" x1="{x0}" y1="{y}" x2="{x1}" y2="{y}"/>')
|
|
1246
|
+
out.append(f'<text class="num" x="{(x0 + x1) / 2}" y="{y - 3}" text-anchor="middle">{H.escape(num)}</text>')
|
|
1247
|
+
if nm and nm != num:
|
|
1248
|
+
tx, anchor = (6, "start") if side == "l" else (w - 6, "end")
|
|
1249
|
+
out.append(f'<text class="name" x="{tx}" y="{y + 4}" text-anchor="{anchor}">{H.escape(nm[:24])}</text>')
|
|
1250
|
+
return "".join(out) + "</svg>"
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def part(ws, key):
|
|
1254
|
+
"""Everything about one hub row: each tool's copy, pictures, sources, release check, sign-off, where-used,
|
|
1255
|
+
lifecycle, history and the fixes on offer."""
|
|
1256
|
+
res = build(ws)
|
|
1257
|
+
row = next((r for r in res["rows"] if r["key"] == key), None)
|
|
1258
|
+
if row is None:
|
|
1259
|
+
raise KeyError(f"no part {key!r} in the library hub")
|
|
1260
|
+
show, cfg = res["_show"], settings(ws)
|
|
1261
|
+
copies = [c for c in res["_copies"] if c["key"] == key]
|
|
1262
|
+
order = {t: i for i, (t, _) in enumerate(TOOLS)}
|
|
1263
|
+
copies.sort(key=lambda c: (order.get(c["tool"], 99), c["origin"] != "ecadlib", c["name"]))
|
|
1264
|
+
pictures, seen = [], set()
|
|
1265
|
+
for c in copies:
|
|
1266
|
+
if c["pads"] and tuple(map(tuple, c["pads"])) not in seen:
|
|
1267
|
+
seen.add(tuple(map(tuple, c["pads"])))
|
|
1268
|
+
pictures.append({"tool": LABEL.get(c["tool"], c["tool"]), "footprint": c["footprint"], "pads": len(c["pads"]),
|
|
1269
|
+
"svg": footprint_picture(c["pads"])})
|
|
1270
|
+
symbol = next(({"tool": LABEL.get(c["tool"], c["tool"]), "symbol": c["symbol"], "pins": len(c["pins"]),
|
|
1271
|
+
"svg": symbol_picture(c["pins"], c["prefix"], c["symbol"] or c["name"])} for c in copies if c["pins"]), None)
|
|
1272
|
+
# the plan item behind ecadlib's copy: sources, release check, sign-off
|
|
1273
|
+
plan_info = None
|
|
1274
|
+
pc = next((c for c in copies if c["plan"] and c["item"] is not None and c["origin"] == "ecadlib"), None) or \
|
|
1275
|
+
next((c for c in copies if c["plan"] and c["item"] is not None), None)
|
|
1276
|
+
history = []
|
|
1277
|
+
if pc:
|
|
1278
|
+
from . import partview as PV
|
|
1279
|
+
plan_f = ws.inside(pc["plan"])
|
|
1280
|
+
items = json.loads(plan_f.read_text(encoding="utf-8"))
|
|
1281
|
+
it = items[pc["item"]]
|
|
1282
|
+
card = PV.cards(plan_f.parent, [it], pictures=False)["parts"][0]
|
|
1283
|
+
appr = [{k: a.get(k) for k in ("who", "when", "role", "decision", "comment")} for a in it.get("approvals") or [] if isinstance(a, dict)]
|
|
1284
|
+
plan_info = {"plan": pc["plan"], "bom": pc["bom"], "status": it.get("status"), "status_label": card.get("status_label"),
|
|
1285
|
+
"reasons": card.get("reasons"), "source": it.get("source") or {}, "field_sources": it.get("field_sources") or {},
|
|
1286
|
+
"datasheet": PV.datasheet_url(it), "tried": ((it.get("librarian") or {}).get("tried") or [])[:6],
|
|
1287
|
+
"notes": ((it.get("librarian") or {}).get("notes") or [])[:6], "approvals": appr,
|
|
1288
|
+
"approval": card.get("approval"), "allowed": bool(it.get("allow"))}
|
|
1289
|
+
history += [{"when": a["when"], "who": a["who"], "what": "approved" if a["decision"] == "approve" else "rejected",
|
|
1290
|
+
"detail": a.get("comment") or (f"as {a['role']}" if a.get("role") else ""), "kind": "sign-off"} for a in appr]
|
|
1291
|
+
a = it.get("a365") or {}
|
|
1292
|
+
if a.get("synced_at"):
|
|
1293
|
+
history.append({"when": iso(a["synced_at"]), "what": "synced with Altium 365", "tool": "altium365",
|
|
1294
|
+
"detail": f"revision {a.get('revision')}" if a.get("revision") else "", "kind": "sync"})
|
|
1295
|
+
names = {c["name"] for c in copies}
|
|
1296
|
+
for e in read_history(ws.root):
|
|
1297
|
+
if e.get("key") == key or (e.get("kind") == "action" and (e.get("name") in names or (pc and e.get("bom") and e["bom"] == pc["bom"]))):
|
|
1298
|
+
history.append(e)
|
|
1299
|
+
history.sort(key=lambda e: e.get("when") or "", reverse=True)
|
|
1300
|
+
for e in history:
|
|
1301
|
+
if e.get("tool"):
|
|
1302
|
+
e["tool_label"] = LABEL.get(e["tool"], e["tool"])
|
|
1303
|
+
wu, _ = _where_used(cfg["projects"])
|
|
1304
|
+
uses = wu.get(key, []) + [u for n in names for u in wu.get("name:" + n.upper(), [])]
|
|
1305
|
+
uses = list({(u["path"], tuple(u["designators"])): u for u in uses}.values())
|
|
1306
|
+
return {**row, "copies": [_public(c, show) for c in copies], "pictures": pictures, "symbol": symbol, "plan": plan_info,
|
|
1307
|
+
"history": history[:200], "where_used": uses, "projects_set": bool(cfg["projects"]),
|
|
1308
|
+
"fixes": fixes(row, copies)}
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def fixes(row, copies):
|
|
1312
|
+
"""The buttons a part gets: [{action, tool, label, why}]."""
|
|
1313
|
+
out = []
|
|
1314
|
+
plan_c = next((c for c in copies if c["plan"] and c["origin"] == "ecadlib" and c["bom"]), None)
|
|
1315
|
+
def own_off(tool):
|
|
1316
|
+
"""ecadlib's own copy for this tool is missing or differs (your library's copies are never rewritten)."""
|
|
1317
|
+
own = [c for c in copies if c["tool"] == tool and c["origin"] == "ecadlib"]
|
|
1318
|
+
return not own or any(c.get("state") in ("different", "behind") for c in own)
|
|
1319
|
+
if plan_c:
|
|
1320
|
+
st = plan_c["status"]
|
|
1321
|
+
if st in ("held", "awaiting_approval"):
|
|
1322
|
+
out.append({"action": "open_plan", "tool": "altium", "label": "See why it is held" if st == "held" else "Sign it off",
|
|
1323
|
+
"why": "ecadlib holds a part back from every tool until its checks pass and it is signed off"})
|
|
1324
|
+
elif own_off("kicad"):
|
|
1325
|
+
out.append({"action": "kicad", "tool": "kicad", "label": "Make the KiCad version",
|
|
1326
|
+
"why": "writes the KiCad symbol, footprint and database library for this part's BOM"})
|
|
1327
|
+
if st == "ready" and own_off("altium365") and not any(c["tool"] == "altium365" and c["origin"] == "library" for c in copies):
|
|
1328
|
+
out.append({"action": "a365_preview", "tool": "altium365", "label": "Send to Altium 365",
|
|
1329
|
+
"why": "a dry run first: shows the request without sending anything"})
|
|
1330
|
+
for tool, r in _READERS.items():
|
|
1331
|
+
if r["fix"] and own_off(tool) and st == "ready":
|
|
1332
|
+
out.append({"action": f"tool:{tool}", "tool": tool, "label": f"Make the {r['label']} version", "why": ""})
|
|
1333
|
+
elif row["mpn"]:
|
|
1334
|
+
out.append({"action": "build", "tool": "", "label": "Build it with ecadlib",
|
|
1335
|
+
"why": "ecadlib finds and checks its own copy from this MPN (it does not convert yours); the hub then "
|
|
1336
|
+
"compares the two and can make the copies for the tools that lack it"})
|
|
1337
|
+
from .hub_copy import TARGETS, source_for
|
|
1338
|
+
offered = {f["tool"] for f in out}
|
|
1339
|
+
for tool in TARGETS:
|
|
1340
|
+
src = None if tool in row["cells"] or tool in offered else source_for(copies, tool)
|
|
1341
|
+
if src:
|
|
1342
|
+
out.append({"action": f"copy:{tool}", "tool": tool, "label": f"Copy to {LABEL[tool]}",
|
|
1343
|
+
"why": f"converts the {LABEL[src['tool']]} copy ({src['where']}) into the hub's {LABEL[tool]} "
|
|
1344
|
+
f"library; the original is left as it is"})
|
|
1345
|
+
if "duplicate" in row["flags"] and plan_c:
|
|
1346
|
+
out.append({"action": "dedupe", "tool": "", "label": "Find duplicates", "why": "the same part is in one tool more than once"})
|
|
1347
|
+
return out
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def copy_parts(ws, keys, tool, project="", who=None):
|
|
1351
|
+
"""Copy parts into the hub's library for tool (hub_copy), and, with a project from [hub] projects, import that
|
|
1352
|
+
library into the project. -> {tool, copied: [{key, name, from, files, notes}], skipped: [{key, name, why}], project}"""
|
|
1353
|
+
from . import hub_copy as HC
|
|
1354
|
+
if tool not in HC.TARGETS:
|
|
1355
|
+
raise ValueError(f"copying into {LABEL.get(tool, tool)} isn't supported yet: the hub copies between "
|
|
1356
|
+
f"{' and '.join(LABEL[t] for t in HC.TARGETS)}")
|
|
1357
|
+
res = build(ws)
|
|
1358
|
+
if project and project not in {p["path"] for p in res["project_list"]}:
|
|
1359
|
+
raise ValueError(f"{project} is not one of the projects under [hub] projects")
|
|
1360
|
+
if project and next(p for p in res["project_list"] if p["path"] == project)["tool"] != tool:
|
|
1361
|
+
raise ValueError(f"{Path(project).name} is not a {LABEL[tool]} project")
|
|
1362
|
+
rows = {r["key"]: r for r in res["rows"]}
|
|
1363
|
+
copied, skipped, events = [], [], []
|
|
1364
|
+
for key in dict.fromkeys(keys):
|
|
1365
|
+
row = rows.get(key)
|
|
1366
|
+
if row is None:
|
|
1367
|
+
skipped.append({"key": key, "name": key, "why": "not in the library hub"})
|
|
1368
|
+
continue
|
|
1369
|
+
if tool in row["cells"]:
|
|
1370
|
+
skipped.append({"key": key, "name": row["name"], "why": f"already in {LABEL[tool]}"})
|
|
1371
|
+
continue
|
|
1372
|
+
src = HC.source_for([c for c in res["_copies"] if c["key"] == key], tool)
|
|
1373
|
+
if not src:
|
|
1374
|
+
skipped.append({"key": key, "name": row["name"], "why": f"no copy ecadlib can convert to {LABEL[tool]}: "
|
|
1375
|
+
f"it needs an Altium SchLib or a KiCad .kicad_sym"})
|
|
1376
|
+
continue
|
|
1377
|
+
try:
|
|
1378
|
+
files, fp, notes = (HC.to_kicad if tool == "kicad" else HC.to_altium)(ws, src, row["name"])
|
|
1379
|
+
except Exception as e: # noqa: BLE001 - one part's failure is reported, the rest go on
|
|
1380
|
+
skipped.append({"key": key, "name": row["name"], "why": f"{type(e).__name__}: {e}"})
|
|
1381
|
+
continue
|
|
1382
|
+
copied.append({"key": key, "name": row["name"], "from": f"{LABEL[src['tool']]} ({src['where']})",
|
|
1383
|
+
"files": [Path(ws.root).resolve().joinpath(f).relative_to(Path(ws.root).resolve()).as_posix()
|
|
1384
|
+
if Path(ws.root).resolve() in Path(f).resolve().parents else str(f) for f in files],
|
|
1385
|
+
"footprint": fp, "notes": notes[:10]})
|
|
1386
|
+
events.append({"when": now(), "who": who, "what": f"copied to {LABEL[tool]} from {LABEL[src['tool']]}",
|
|
1387
|
+
"kind": "action", "key": key, "name": row["name"], "tool": tool})
|
|
1388
|
+
imported = HC.import_into_project(ws, project, tool) if project and copied else None
|
|
1389
|
+
if imported:
|
|
1390
|
+
events.append({"when": now(), "who": who, "what": imported, "kind": "action", "tool": tool, "file": project})
|
|
1391
|
+
record(ws.root, events)
|
|
1392
|
+
return {"tool": tool, "copied": copied, "skipped": skipped, "project": imported,
|
|
1393
|
+
"library": str(HC.library_dir(ws) / ("kicad" if tool == "kicad" else "altium"))}
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def one_line_bom(row, copies):
|
|
1397
|
+
"""A one-part BOM (CSV text) for "Build it with ecadlib"."""
|
|
1398
|
+
import csv, io
|
|
1399
|
+
c = next((c for c in copies if c["footprint"]), copies[0])
|
|
1400
|
+
buf = io.StringIO()
|
|
1401
|
+
w = csv.writer(buf)
|
|
1402
|
+
w.writerow(["Designator", "Quantity", "Manufacturer", "Manufacturer Part Number", "Value", "Footprint", "Description"])
|
|
1403
|
+
w.writerow([f"{c['prefix'] or 'U'}1", 1, row["manufacturer"], row["mpn"], c["value"] or "",
|
|
1404
|
+
str(c["footprint"]).split(":")[-1], row["description"]])
|
|
1405
|
+
return buf.getvalue().encode("utf-8")
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def copies_of(ws, key):
|
|
1409
|
+
res = build(ws)
|
|
1410
|
+
row = next((r for r in res["rows"] if r["key"] == key), None)
|
|
1411
|
+
if row is None:
|
|
1412
|
+
raise KeyError(f"no part {key!r} in the library hub")
|
|
1413
|
+
return row, [c for c in res["_copies"] if c["key"] == key]
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
# ------------------------------------------------------------------ command line
|
|
1417
|
+
def _table(rows, tools):
|
|
1418
|
+
out = []
|
|
1419
|
+
head = ["Part", *[t["label"] for t in tools if t["reader"]], "Updated", "By"]
|
|
1420
|
+
out.append(" | ".join(head))
|
|
1421
|
+
mark = {"same": "ok", "present": "yes", "different": "DIFF", "behind": "BEHIND"}
|
|
1422
|
+
for r in rows:
|
|
1423
|
+
cells = [mark.get(r["cells"].get(t["tool"], {}).get("state"), "-") for t in tools if t["reader"]]
|
|
1424
|
+
out.append(" | ".join([r["name"][:40], *cells, (r["updated"] or "")[:10], (r["updated_by"] or "not recorded")[:30]]))
|
|
1425
|
+
return "\n".join(out)
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def main(argv=None):
|
|
1429
|
+
from .app import Workspace
|
|
1430
|
+
ap = argparse.ArgumentParser(prog="ecadlib hub", description="your library across tools: every part once, a column per tool")
|
|
1431
|
+
ap.add_argument("--workspace", default=os.environ.get("ECADLIB_WORKSPACE") or "ecadlib_workspace")
|
|
1432
|
+
ap.add_argument("--lib", action="append", default=[], help="library folder or file to include (repeatable)")
|
|
1433
|
+
ap.add_argument("-q", "--search", default="", help="words to look for in name, MPN, maker, description")
|
|
1434
|
+
ap.add_argument("--have", default="", help="only parts in these tools (comma list: altium,altium365,kicad,fusion,easyeda)")
|
|
1435
|
+
ap.add_argument("--lack", default="", help="only parts missing from these tools")
|
|
1436
|
+
ap.add_argument("--state", default="", help="only parts with a cell in these states (different,behind,present,same)")
|
|
1437
|
+
ap.add_argument("--tool", default="", help="one tool's library only (altium, altium365, kicad, fusion, easyeda)")
|
|
1438
|
+
ap.add_argument("--project", default="", help="only parts this project uses (a .kicad_pro / .PrjPcb under [hub] projects)")
|
|
1439
|
+
ap.add_argument("--copy-to", default="", choices=["", "altium", "kicad"],
|
|
1440
|
+
help="copy the listed parts that this tool lacks into the hub library (and into --project)")
|
|
1441
|
+
ap.add_argument("--part", default="", help="show one part (its key or name)")
|
|
1442
|
+
ap.add_argument("--json", action="store_true", help="print JSON")
|
|
1443
|
+
a = ap.parse_args(argv)
|
|
1444
|
+
ws = Workspace(a.workspace, a.lib, offline=True)
|
|
1445
|
+
split = lambda s: [x.strip() for x in s.split(",") if x.strip()]
|
|
1446
|
+
if a.part:
|
|
1447
|
+
res = build(ws)
|
|
1448
|
+
r = next((r for r in res["rows"] if a.part in (r["key"], r["name"]) or mpn_key(a.part) == r["key"]), None)
|
|
1449
|
+
if not r:
|
|
1450
|
+
print(f"no part {a.part!r} in the library hub", file=sys.stderr)
|
|
1451
|
+
return 2
|
|
1452
|
+
p = part(ws, r["key"])
|
|
1453
|
+
if a.json:
|
|
1454
|
+
print(json.dumps({k: v for k, v in p.items() if k not in ("pictures", "symbol")}, indent=1, default=str))
|
|
1455
|
+
else:
|
|
1456
|
+
print(f"{p['name']} {p['mpn']} {p['manufacturer']}")
|
|
1457
|
+
for c in p["copies"]:
|
|
1458
|
+
print(f" {c['tool_label']:<11} {c['state']:<9} {c['where']} updated {c['updated'] or '?'} by {c['updated_by'] or 'not recorded'}")
|
|
1459
|
+
for d in c["diffs"] or []:
|
|
1460
|
+
print(f" {d['what']}: {d['text']}")
|
|
1461
|
+
for e in p["history"][:15]:
|
|
1462
|
+
print(f" {e.get('when', '')[:19]} {e.get('who') or '':<24} {e.get('what')} {e.get('tool_label') or ''} {e.get('detail') or ''}")
|
|
1463
|
+
return 0
|
|
1464
|
+
res = listing(ws, a.search, split(a.have), split(a.lack), split(a.state), size=500, tool=a.tool,
|
|
1465
|
+
project=str(Path(a.project).resolve()) if a.project else "")
|
|
1466
|
+
if a.copy_to:
|
|
1467
|
+
out = copy_parts(ws, [r["key"] for r in res["rows"] if a.copy_to not in r["cells"]], a.copy_to,
|
|
1468
|
+
str(Path(a.project).resolve()) if a.project else "")
|
|
1469
|
+
if a.json:
|
|
1470
|
+
print(json.dumps(out, indent=1, default=str))
|
|
1471
|
+
else:
|
|
1472
|
+
print(f"copied {len(out['copied'])} part(s) to {LABEL[a.copy_to]} in {out['library']}" +
|
|
1473
|
+
(f"; {out['project']}" if out["project"] else ""))
|
|
1474
|
+
for x in out["skipped"]:
|
|
1475
|
+
print(f" skipped {x['name']}: {x['why']}")
|
|
1476
|
+
return 0
|
|
1477
|
+
if a.json:
|
|
1478
|
+
print(json.dumps(res, indent=1, default=str))
|
|
1479
|
+
else:
|
|
1480
|
+
s = res["summary"]
|
|
1481
|
+
print(f"{s['parts']} parts ({s['ecadlib']} built by ecadlib, {s['library']} in your libraries); "
|
|
1482
|
+
f"{s['different']} different, {s['behind']} behind, {s['held']} held, {s['eol']} end of life")
|
|
1483
|
+
print(_table(res["rows"], res["tools"]))
|
|
1484
|
+
for e in res["errors"][:10]:
|
|
1485
|
+
print(f"couldn't read {e}", file=sys.stderr)
|
|
1486
|
+
return 0
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
if __name__ == "__main__":
|
|
1490
|
+
sys.exit(main())
|