ecadlib 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/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())