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/app.py ADDED
@@ -0,0 +1,1458 @@
1
+ """Shared actions behind every front end (web UI, MCP server, `ecadlib` CLI).
2
+
3
+ A Workspace is one folder on the user's machine:
4
+
5
+ <root>/uploads/<stamp>-<rand>/<name> BOMs dropped into the UI, one folder per upload (never overwritten)
6
+ <root>/footprints/ecadlib.pretty/ footprints the user approved (indexed like any other library)
7
+ <root>/altium_models/PcbLib|SchLib/ Altium binaries: approved footprints, converted library footprints,
8
+ generic R/C symbols (what an Altium 365 release uploads)
9
+ <root>/reports/ triage .md/.csv, plus .json (the full result, for triage_history)
10
+ <root>/altium/ DbLib export
11
+ <root>/altium365/ release plan + push reports
12
+ <root>/librarian/<bom>/ librarian output: models, plan.json, review and release-check reports
13
+ <root>/http_cache/ provider responses
14
+
15
+ Each action returns plain JSON-able dicts so the UI and the MCP server can share it."""
16
+ import contextlib, datetime, hashlib, html, itertools, json, math, os, re, secrets
17
+ from pathlib import Path
18
+
19
+ import pandas as pd
20
+
21
+ from . import bom_triage as T, footprint_gen as F, providers as P, rules as RU, sexpr
22
+ from .fsutil import write_text_atomic
23
+ from .httpcache import CachedHTTP
24
+ from .kicad_index import KiCadIndex
25
+
26
+ STATUS_ORDER = ["needs_footprint_generation", "unverified (source unreachable)", "easyeda_footprint_only",
27
+ "available_easyeda", "in_library (generated footprint)", "in_library"]
28
+
29
+
30
+ class Workspace:
31
+ def __init__(self, root="ecadlib_workspace", libs=(), providers=None, offline=False):
32
+ self.root = Path(root).resolve()
33
+ for d in ("uploads", "reports", "altium", "altium365", "footprints/ecadlib.pretty",
34
+ "altium_models/PcbLib", "altium_models/SchLib"):
35
+ (self.root / d).mkdir(parents=True, exist_ok=True)
36
+ from . import migrate as MG # files from a newer ecadlib: one clear error, nothing misread
37
+ MG.guard_tree(self.root)
38
+ self.gen_dir = self.root / "footprints" / "ecadlib.pretty"
39
+ self.models = self.root / "altium_models"
40
+ self.libs = [str(Path(l).resolve()) for l in libs]
41
+ self.provider_names = providers or [c.name for c in P.ALL]
42
+ self._providers_given = bool(providers) # else [providers] order in the rules decides (ecadlib.plugins)
43
+ self.offline = offline
44
+
45
+ # ------------------------------------------------------------------ paths
46
+ def safe_name(self, name, strict=False):
47
+ """A plain file name. Uploads keep only their base name; footprint names (strict) with a path
48
+ separator are refused, so "SOIC-8/TI_D" can't quietly become "TI_D" and collide with another part."""
49
+ name = str(name)
50
+ if strict and ("/" in name or "\\" in name):
51
+ plain = re.sub(r"[/\\]+", "_", name)
52
+ raise ValueError(f"footprint name {name!r} contains a path separator; use a plain name such as {plain!r}")
53
+ name = re.sub(r"[^A-Za-z0-9._-]+", "_", re.split(r"[/\\]", name)[-1]).strip("._") or "bom.csv"
54
+ return name[:120]
55
+
56
+ def inside(self, path):
57
+ """Resolve a workspace-relative path, refusing anything outside the workspace."""
58
+ p = (self.root / path).resolve()
59
+ if p != self.root and self.root not in p.parents:
60
+ raise ValueError("path outside workspace")
61
+ return p
62
+
63
+ def rel(self, p):
64
+ return Path(p).resolve().relative_to(self.root).as_posix()
65
+
66
+ def _bom_path(self, bom):
67
+ return Path(bom) if Path(bom).is_absolute() else self.inside(bom)
68
+
69
+ def _index(self):
70
+ return KiCadIndex(self.libs + [str(self.root / "footprints")])
71
+
72
+ def _read_bom(self, bom_path):
73
+ try:
74
+ found = bom_path.is_file()
75
+ except OSError: # name too long, ...
76
+ found = False
77
+ if not found:
78
+ raise ValueError(f"BOM not found: {bom_path.name[:80]} is not a file in the workspace")
79
+ head = bom_path.read_bytes()[:4096] # UTF-16 with a byte-order mark: Excel "Unicode Text", fine
80
+ if (bom_path.suffix.lower() not in (".xlsx", ".xlsm", ".xls", ".schdoc", ".prjpcb", ".bomdoc")
81
+ and b"\0" in head and head[:2] not in (b"\xff\xfe", b"\xfe\xff") and not T.utf16_without_bom(head)):
82
+ raise ValueError(f"{bom_path.name} looks like a binary file, not a CSV; export the BOM as CSV or XLSX")
83
+ try:
84
+ lines, mapping = T.read_bom(bom_path, RU.bom_columns(self.rules()))
85
+ except Exception as e: # pandas/csv/zip errors -> one message a person can act on
86
+ if type(e) is ValueError: # the reader's own words: no exception name, no server path
87
+ raise ValueError(f"couldn't read {bom_path.name} as a BOM: "
88
+ + str(e).replace(str(bom_path), bom_path.name).replace(f"{bom_path.name}: ", "", 1)) from e
89
+ raise ValueError(f"couldn't read {bom_path.name} as a BOM table ({type(e).__name__}: {e}). "
90
+ "Save it as CSV or XLSX with a header row.") from e
91
+ if not lines or not {"designator", "mpn", "value"} & set(mapping):
92
+ raise ValueError(f"no BOM lines found in {bom_path.name}: it needs a header row with columns such as "
93
+ "Designator, Quantity and Manufacturer Part Number, followed by at least one part")
94
+ return lines, mapping
95
+
96
+ def _key(self, bom_path):
97
+ """Report name for a BOM: its stem for BOMs in uploads/ (plus the upload's folder name for
98
+ uploads/<stamp>-<rand>/<name>), plus a short hash of the full path for any other location, so
99
+ /projA/BOM.csv and /projB/BOM.csv (or two uploads called BOM.csv) don't share (and overwrite) one triage."""
100
+ bom_path = Path(bom_path).resolve()
101
+ if bom_path.parent == self.root / "uploads":
102
+ return bom_path.stem
103
+ if bom_path.parent.parent == self.root / "uploads":
104
+ return f"{bom_path.stem}_{bom_path.parent.name}"
105
+ return f"{bom_path.stem}_{hashlib.sha1(str(bom_path).encode()).hexdigest()[:8]}"
106
+
107
+ def _triage_csv(self, bom_path):
108
+ return self.root / "reports" / f"triage_{self._key(bom_path)}.csv"
109
+
110
+ def _triage_frame(self, bom_path):
111
+ tri = self._triage_csv(bom_path)
112
+ return pd.read_csv(tri, dtype=str).fillna("") if tri.exists() else None
113
+
114
+ # ------------------------------------------------------------------ actions
115
+ def info(self):
116
+ http = CachedHTTP(self.root / "http_cache", offline=True)
117
+ index = self._index()
118
+ return {"workspace": str(self.root), "libs": self.libs, "offline": self.offline,
119
+ "symbols": len(index.symbols), "footprints": len(index.footprints),
120
+ "providers": [{"name": c.name, "auth": c.auth, "enabled": c.name in self.provider_names,
121
+ "configured": c(http).configured()} for c in P.ALL],
122
+ "altium365": {"token": bool(os.environ.get("NEXAR_TOKEN"))},
123
+ "rules": (lambda f: self._label(f) if f else None)(self.rules_in_use()),
124
+ "altium_models": {k: sorted(p.stem for p in (self.models / k).glob(f"*.{k}")) for k in ("PcbLib", "SchLib")},
125
+ "boms": self._uploaded_boms()}
126
+
127
+ def _uploaded_boms(self):
128
+ """uploads/<name> (older workspaces) and uploads/<stamp>-<rand>/<name>, newest upload folder first.
129
+ Links are skipped, so nothing outside the workspace is listed."""
130
+ up = self.root / "uploads"
131
+ flat = sorted(p for p in up.iterdir() if p.is_file() and not p.is_symlink())
132
+ nested = [f for d in sorted((d for d in up.iterdir() if d.is_dir() and not d.is_symlink()), reverse=True)
133
+ for f in sorted(d.iterdir()) if f.is_file() and not f.is_symlink()]
134
+ return [self.rel(p) for p in nested + flat if self._is_inside(p)]
135
+
136
+ def _is_inside(self, p):
137
+ try:
138
+ self.inside(p)
139
+ return True
140
+ except ValueError:
141
+ return False
142
+
143
+ def save_upload(self, filename, data: bytes):
144
+ """Store an uploaded BOM as uploads/<UTC stamp>-<random>/<plain file name> and return that path. Every
145
+ upload gets its own new folder, so two people uploading BOM.csv never overwrite each other (nor each
146
+ other's triage reports, which are keyed by the folder too). The file keeps its (sanitised) name."""
147
+ if not data:
148
+ raise ValueError(f"{filename} is empty")
149
+ name = self.safe_name(filename)
150
+ stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S")
151
+ for _ in range(20):
152
+ d = self.root / "uploads" / f"{stamp}-{secrets.token_hex(3)}"
153
+ try:
154
+ d.mkdir() # atomic: a folder another upload made is never reused
155
+ break
156
+ except FileExistsError:
157
+ continue
158
+ else:
159
+ raise FileExistsError("couldn't make a new upload folder; try again")
160
+ p = d / name
161
+ fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o644)
162
+ with os.fdopen(fd, "wb") as f:
163
+ f.write(data)
164
+ return self.rel(p)
165
+
166
+ def triage(self, bom, progress=None, lock=None, user=None):
167
+ """Triage a BOM (workspace-relative or absolute path). Writes reports/triage_<stem>.{md,csv,json}; the
168
+ .json is the whole result plus when and by whom (user), which triage_history lists and opens again.
169
+ progress(done, total, label) is called before each line, for UIs that show a progress bar.
170
+ lock, if given, is held only while the reports are written: the lookups are network-bound and
171
+ must not block other workspace actions."""
172
+ bom_path = self._bom_path(bom)
173
+ http = CachedHTTP(self.root / "http_cache", min_interval=1.0, offline=self.offline)
174
+ provs = [c(http) for c in self._provider_classes()]
175
+ lines, mapping = self._read_bom(bom_path)
176
+ index = self._index()
177
+ rows = []
178
+ for i, ln in enumerate(lines, 1): # one line at a time so long lookups can report progress
179
+ if progress:
180
+ progress(i - 1, len(lines), ln.get("mpn") or ln.get("value") or ln.get("designator", ""))
181
+ r = T.triage([ln], index, provs, http)[0]
182
+ r["line"] = i
183
+ rows.append(r)
184
+ if progress:
185
+ progress(len(lines), len(lines), "writing report")
186
+ stem = self.root / "reports" / f"triage_{self._key(bom_path)}"
187
+ with lock or contextlib.nullcontext():
188
+ counts = T.write_reports(rows, mapping, provs, http, stem, bom_path.name)
189
+ out = {"bom": self.rel(bom_path) if self.root in bom_path.resolve().parents else str(bom_path),
190
+ "mapping": mapping, "rows": rows,
191
+ "counts": {k: int(v) for k, v in counts.items()},
192
+ "reports": [self.rel(stem.with_suffix(".md")), self.rel(stem.with_suffix(".csv"))]}
193
+ meta = {"id": self._key(bom_path), "name": bom_path.name, "finished": _now(), "user": user or None}
194
+ tmp = stem.with_name(stem.name + f".{os.getpid()}.{secrets.token_hex(3)}.tmp")
195
+ tmp.write_text(json.dumps({**meta, **out}, default=str), encoding="utf-8")
196
+ os.replace(tmp, stem.with_suffix(".json"))
197
+ return {**out, **meta}
198
+
199
+ # ------------------------------------------------------------------ past triages (read-only)
200
+ def _triage_files(self):
201
+ """{id: (json or None, csv or None)} of every triage report in reports/ (id = the BOM's report key)."""
202
+ out = {}
203
+ for f in (self.root / "reports").glob("triage_*"):
204
+ if f.suffix in (".json", ".csv") and f.is_file() and not f.is_symlink() and self._is_inside(f):
205
+ j, c = out.get(f.stem[len("triage_"):], (None, None))
206
+ out[f.stem[len("triage_"):]] = (f, c) if f.suffix == ".json" else (j, f)
207
+ return out
208
+
209
+ def triage_history(self, limit=200):
210
+ """Finished triages in this workspace, newest first: id, BOM, file name, when, who ran it (team mode),
211
+ status counts and report links. Older triages that only left a .csv are listed too (no BOM / user)."""
212
+ items = []
213
+ for tid, (j, c) in self._triage_files().items():
214
+ try:
215
+ if j:
216
+ d = json.loads(j.read_text(encoding="utf-8"))
217
+ it = {k: d.get(k) for k in ("bom", "name", "finished", "user", "counts")}
218
+ it["lines"] = len(d.get("rows") or [])
219
+ it["reports"] = d.get("reports") or []
220
+ elif c:
221
+ rows = pd.read_csv(c, dtype=str).fillna("")
222
+ st = rows["status"].value_counts() if "status" in rows else {}
223
+ it = {"bom": None, "name": tid, "user": None, "lines": len(rows),
224
+ "counts": {k: int(v) for k, v in dict(st).items()},
225
+ "finished": _now(c.stat().st_mtime),
226
+ "reports": [self.rel(x) for x in (c.with_suffix(".md"), c) if x.exists()]}
227
+ else:
228
+ continue
229
+ except (ValueError, OSError, KeyError, pd.errors.ParserError, pd.errors.EmptyDataError):
230
+ continue # a damaged report must not hide the others
231
+ it["id"], mtime = tid, max(x.stat().st_mtime_ns for x in (j, c) if x)
232
+ it["bom_exists"] = bool(it["bom"]) and self._is_inside(it["bom"]) and self.inside(it["bom"]).is_file()
233
+ items.append((it.get("finished") or "", mtime, it))
234
+ items.sort(key=lambda x: x[:2], reverse=True) # same second: the file written last first
235
+ return {"triages": [x[2] for x in items[:limit]], "total": len(items)}
236
+
237
+ def triage_result(self, tid):
238
+ """One finished triage by its id (from triage_history), in the shape triage() returns. Read-only: the id
239
+ is only matched against the reports that exist, never turned into a path."""
240
+ f = self._triage_files().get(str(tid or ""))
241
+ if not f:
242
+ raise KeyError(f"no triage {tid!r} in this workspace")
243
+ j, c = f
244
+ if j:
245
+ d = json.loads(j.read_text(encoding="utf-8"))
246
+ d["id"] = tid
247
+ return d
248
+ rows = pd.read_csv(c, dtype=str).fillna("").to_dict("records")
249
+ for r in rows:
250
+ with contextlib.suppress(ValueError, KeyError):
251
+ r["line"] = int(r["line"])
252
+ st = pd.Series([r.get("status", "") for r in rows]).value_counts() if rows else {}
253
+ return {"id": tid, "bom": None, "name": tid, "user": None, "finished": _now(c.stat().st_mtime), "mapping": {},
254
+ "rows": rows, "counts": {k: int(v) for k, v in dict(st).items()},
255
+ "reports": [self.rel(x) for x in (c.with_suffix(".md"), c) if x.exists()]}
256
+
257
+ def preview_footprint(self, spec, density=None):
258
+ """Run the IPC-7351B generator without saving. Returns numbers, pads and an SVG drawing."""
259
+ if isinstance(spec, str):
260
+ spec = json.loads(spec)
261
+ if not isinstance(spec, dict) or not spec.get("name"):
262
+ raise ValueError("the package spec needs at least a \"name\"")
263
+ try:
264
+ F.validate(spec)
265
+ text, rep = F.build(spec, RU.density_for(spec, self.rules(), density))
266
+ except KeyError as e: # no jsonschema installed: the generator finds the gap itself
267
+ raise ValueError(f"the package spec is missing {e.args[0]!r}") from e
268
+ except Exception as e:
269
+ if type(e).__name__ != "ValidationError":
270
+ raise
271
+ where = "/".join(map(str, e.absolute_path))
272
+ raise ValueError(f"the package spec isn't valid{' at ' + where if where else ''}: {e.message}") from e
273
+ sexpr.parse(text)
274
+ name = self.safe_name(spec["name"], strict=True)
275
+ return {"name": spec["name"], "report": rep, "kicad_mod": text, "svg": footprint_svg(rep, kicad_mod=text),
276
+ "exists": (self.gen_dir / f"{name}.kicad_mod").exists()}
277
+
278
+ def preview_symbol(self, pins, name="PART", split=None, stack=None, save=False, overwrite=False):
279
+ """IC symbol with pins grouped by function (ecadlib.symbol_layout) under the workspace rules' [symbols].
280
+ pins: [{num, name, type?, group?}] or a CSV/JSON path. Returns groups, parts, problems and an SVG; save=True
281
+ also writes <workspace>/altium_models/SchLib/<name>.SchLib (refused if it exists unless overwrite)."""
282
+ from . import altium_lib, symbol_layout as SL
283
+ if isinstance(pins, str):
284
+ p = self._bom_path(pins)
285
+ name_, pins, _ = SL.load_pins(p)
286
+ name = name if name != "PART" else name_
287
+ sec = dict(self.rules()["symbols"])
288
+ sec.update({k: v for k, v in (("split_pins", split), ("stack_power", stack)) if v is not None})
289
+ sym = SL.build(name, pins, rules={"symbols": sec})
290
+ groups = {}
291
+ for q in SL.arrange_pins(pins, {"symbols": sec}):
292
+ groups.setdefault(f"{chr(64 + q['unit'])}:{q['side']}:{q['group']}", []).append(q["num"])
293
+ out = {"name": name, "pins": len(pins), "parts": sym.get("parts", 1), "groups": groups,
294
+ "problems": SL.check(sym, sec["grid"]), "svg": SL.svg(sym)}
295
+ if save:
296
+ f = self.models / "SchLib" / f"{altium_lib.file_stem(self.safe_name(name, strict=True))}.SchLib"
297
+ if f.exists() and not overwrite:
298
+ raise FileExistsError(f"{f.name} is already in the workspace; save again with overwrite to replace it")
299
+ f.parent.mkdir(parents=True, exist_ok=True)
300
+ out["file"] = self.rel(altium_lib.write_schlib(f, [sym]))
301
+ return out
302
+
303
+ def approve_footprint(self, spec, density=None, overwrite=False):
304
+ """Save the generated footprint into the workspace library (KiCad .kicad_mod + Altium .PcbLib),
305
+ where triage and the Altium 365 plan will find it. Refuses to replace an existing one unless overwrite."""
306
+ from . import altium_lib
307
+ spec_obj = json.loads(spec) if isinstance(spec, str) else spec
308
+ res = self.preview_footprint(spec_obj, density)
309
+ name = self.safe_name(res["name"], strict=True)
310
+ p = self.gen_dir / f"{name}.kicad_mod"
311
+ if p.exists() and not overwrite:
312
+ raise FileExistsError(f"{name} is already in the workspace library; approve again with overwrite to replace it")
313
+ p.write_text(res["kicad_mod"], encoding="utf-8", newline="\n")
314
+ (self.gen_dir / f"{name}.json").write_text(json.dumps(spec_obj, indent=2), encoding="utf-8") # keep the source dimensions
315
+ files = [self.rel(p)]
316
+ warnings = [f"{c['rule']}: {c['message']}" for c in res["report"].get("checks", []) if c["level"] == "error"]
317
+ try:
318
+ rules = self.rules()
319
+ fp, _ = altium_lib.footprint_from_spec(spec_obj, RU.density_for(spec_obj, rules, density))
320
+ files.append(self.rel(altium_lib.write_pcblib(self.models / "PcbLib" / f"{name}.PcbLib", [fp], RU.layers(rules))))
321
+ except Exception as e:
322
+ warnings.append(f"Altium .PcbLib not written: {type(e).__name__}: {e}")
323
+ return {"name": name, "path": files[0], "files": files, "warnings": warnings, "report": res["report"]}
324
+
325
+ def build_altium_models(self, bom):
326
+ """Write the Altium files an Altium 365 release needs, for every BOM line that has a footprint:
327
+ footprints already in the KiCad libraries are converted to .PcbLib, and generic Resistor/Capacitor
328
+ symbols are written as .SchLib. IC symbols are not generated here."""
329
+ from . import altium_lib
330
+ bom_path = self._bom_path(bom)
331
+ tri = self._triage_frame(bom_path)
332
+ if tri is None:
333
+ self.triage(bom)
334
+ tri = self._triage_frame(bom_path)
335
+ index, done, skipped, layers = self._index(), [], [], RU.layers(self.rules())
336
+ for key in dict.fromkeys(k.split(" ")[0] for k in tri["local_footprint"] if k):
337
+ ref = key.split(":")[-1]
338
+ out = self.models / "PcbLib" / f"{ref}.PcbLib"
339
+ if out.exists():
340
+ done.append(ref)
341
+ continue
342
+ hit = index.footprints.get(key) or index.footprint(key)[1]
343
+ if not hit:
344
+ skipped.append(f"{ref}: not found in indexed libraries")
345
+ continue
346
+ try:
347
+ altium_lib.write_pcblib(out, [altium_lib.footprint_from_kicad(Path(hit["path"]).read_text(encoding="utf-8"))], layers)
348
+ done.append(ref)
349
+ except Exception as e:
350
+ skipped.append(f"{ref}: {e}")
351
+ for name, make in (("Resistor", altium_lib.resistor_symbol), ("Capacitor", altium_lib.capacitor_symbol)):
352
+ out = self.models / "SchLib" / f"{name}.SchLib"
353
+ if not out.exists():
354
+ altium_lib.write_schlib(out, [make()])
355
+ return {"footprints": done, "skipped": skipped,
356
+ "symbols": sorted(p.stem for p in (self.models / "SchLib").glob("*.SchLib"))}
357
+
358
+ def export_dblib(self, bom):
359
+ """Write the Altium DbLib (SQLite + XLSX + .DbLib) for a BOM, using the latest triage."""
360
+ from . import altium_export
361
+ bom_path = self._bom_path(bom)
362
+ self._read_bom(bom_path) # same friendly error as triage for unreadable files
363
+ tri = self._triage_csv(bom_path)
364
+ out = self.root / "altium"
365
+ argv = [str(bom_path), "-o", str(out)] + (["--triage", str(tri)] if tri.exists() else [])
366
+ rf = self.rules_in_use()
367
+ argv += ["--rules", str(rf)] if rf else []
368
+ if altium_export.main(argv):
369
+ raise ValueError(f"couldn't write the DbLib for {bom_path.name}: no part lines in it")
370
+ return {"files": [self.rel(p) for p in sorted(out.iterdir()) if p.is_file()]}
371
+
372
+ def altium365_plan(self, bom):
373
+ """One workspace component per part. When the librarian has run for this BOM its plan is used, with the
374
+ release check's ready / held verdicts; otherwise a part is ready when its .SchLib and .PcbLib are in
375
+ altium_models/."""
376
+ from . import altium365 as A
377
+ bom_path = self._bom_path(bom)
378
+ lines, _ = self._read_bom(bom_path)
379
+ lib_plan = self._librarian_dir(bom_path) / "plan.json"
380
+ if lib_plan.exists():
381
+ items = json.loads(lib_plan.read_text(encoding="utf-8"))
382
+ for it in items: # librarian paths are relative to its output folder
383
+ for k in ("symbol", "footprint"):
384
+ f = it[k].get("file")
385
+ if f and not Path(f).is_absolute():
386
+ it[k]["file"] = str(lib_plan.parent / f)
387
+ src, out = "librarian", lib_plan
388
+ else:
389
+ items = A.plan(lines, self._triage_frame(bom_path), self.models, rules=self.rules())
390
+ out = self.root / "altium365" / f"plan_{self._key(bom_path)}.json"
391
+ out.write_text(json.dumps(items, indent=2), encoding="utf-8")
392
+ src = "triage"
393
+ ready = sum(i["status"] == "ready" for i in items)
394
+ return {"items": items, "ready": ready, "total": len(items), "plan": self.rel(out), "source": src}
395
+
396
+ # ------------------------------------------------------------------ librarian
397
+ def _librarian_dir(self, bom_path):
398
+ return self.root / "librarian" / self._key(bom_path)
399
+
400
+ def librarian_run(self, bom, sources=None, allow=(), refresh=None, progress=None):
401
+ """BOM -> library in one go (ecadlib.librarian): triage, fill every missing symbol and footprint from
402
+ the KiCad libraries, EasyEDA or the datasheet, write Altium models and the plan, run the release check.
403
+ refresh: None resumes (finished parts are skipped), [] redoes everything, [names] redoes those."""
404
+ from . import librarian as L
405
+ bom_path = self._bom_path(bom)
406
+ lines, _ = self._read_bom(bom_path) # same friendly error as triage for unreadable files
407
+ out = self._librarian_dir(bom_path)
408
+ log, n = [], [0]
409
+
410
+ def note(msg):
411
+ log.append(str(msg).strip())
412
+ n[0] += 1
413
+ if progress:
414
+ progress(min(n[0], len(lines)), len(lines), str(msg).strip()[:80])
415
+ if progress:
416
+ progress(0, len(lines), "looking up parts")
417
+ provs = self._librarian_providers()
418
+ items = L.run(str(bom_path), self.libs + [str(self.root / "footprints")], out,
419
+ list(sources) if sources else None, refresh, False, list(allow), self.offline,
420
+ self.root / "http_cache", 0.5, provs, log=note, rules=self.rules())
421
+ return self._librarian_summary(bom_path, items, log)
422
+
423
+ def release_check(self, bom, allow=(), unallow=(), user=None):
424
+ """Re-run the release check on the librarian's plan, optionally releasing named parts despite their
425
+ failures (allow) or taking that back (unallow). user, when given, is recorded as the person who gave the
426
+ allow (approvals' no_self_approval uses it). Never touches the network."""
427
+ with self._plan_lock():
428
+ return self._release_check(bom, allow, unallow, user)
429
+
430
+ def _release_check(self, bom, allow=(), unallow=(), user=None):
431
+ from . import release_check as RC
432
+ bom_path = self._bom_path(bom)
433
+ out = self._librarian_dir(bom_path)
434
+ plan_f = out / "plan.json"
435
+ if not plan_f.exists():
436
+ raise ValueError("no librarian plan for this BOM yet: run the librarian (Fill missing parts) first")
437
+ items = json.loads(plan_f.read_text(encoding="utf-8"))
438
+ unknown = set(allow) | set(unallow)
439
+ unknown -= {i["name"] for i in items}
440
+ if unknown:
441
+ raise ValueError(f"not parts of this plan: {', '.join(sorted(unknown))}")
442
+ rows = RC.apply(items, out, list(allow), list(unallow), who="ecadlib ui/mcp", rules=self.rules())
443
+ user = user.strip() if isinstance(user, str) else ""
444
+ if allow and user:
445
+ from .approvals import post_check, stamp_allow
446
+ stamp_allow(items, list(allow), user)
447
+ post_check(items, out, rows)
448
+ write_text_atomic(plan_f, json.dumps(items, indent=2))
449
+ RC.write_report(rows, out / "release_check", plan_f)
450
+ from . import librarian as L
451
+ L.write_review(L.review_rows(items), out, str(bom_path))
452
+ return self._librarian_summary(bom_path, items, [])
453
+
454
+ @contextlib.contextmanager
455
+ def _plan_lock(self):
456
+ """<workspace>/agent/plan.lock around a plan rewrite (not re-entrant: locked methods call the unlocked _bodies): while the library agent's
457
+ librarian rewrites a plan, a change written now would be overwritten by its copy, so it is refused instead."""
458
+ from .agent_queue import plan_lock
459
+ with plan_lock(self.root, wait=False) as free:
460
+ if not free:
461
+ raise ValueError("the library agent is rebuilding the library right now; try again in a minute")
462
+ yield
463
+
464
+ def _librarian_summary(self, bom_path, items, log):
465
+ from . import librarian as L, reports as RP
466
+ out = self._librarian_dir(bom_path)
467
+ rows = L.review_rows(items)
468
+ for r, it in zip(rows, items):
469
+ r["allowed"] = bool(it.get("allow"))
470
+ a = it.get("approval")
471
+ r["approval"] = {k: a.get(k) for k in ("required", "met", "approved_by", "missing", "stale")} if a else None
472
+ if it.get("duplicate_of"):
473
+ r["duplicate_of"] = it["duplicate_of"]
474
+ v = RP.part_view(it) # the shared voice: status word, reasons and checks with a next step
475
+ r["word"] = v["word"]
476
+ r["says"] = [{k: e[k] for k in ("code", "text", "fix", "note")} for e in v["reasons"] + v["checks"]]
477
+ files = [out / f for f in ("review.md", "review.html", "release_check.md", "plan.json", "dedupe.md")]
478
+ return {"bom": self.rel(bom_path) if self._is_inside(bom_path) else str(bom_path),
479
+ "parts": len(items), "ready": sum(i["status"] == "ready" for i in items),
480
+ "held": sum(i["status"] == "held" for i in items),
481
+ "awaiting_approval": sum(i["status"] == "awaiting_approval" for i in items),
482
+ "duplicates": sum(i["status"] == "duplicate" for i in items),
483
+ "needs_person": sum(bool(r["needs_human"]) for r in rows), "review": rows,
484
+ "reports": [self.rel(f) for f in files if f.exists()], "log": log[-60:]}
485
+
486
+ # ------------------------------------------------------------------ held parts (ecadlib.resolve)
487
+ def _plan(self, bom):
488
+ """(bom_path, plan.json path, items) of the librarian plan for a BOM; the plan is always inside the workspace."""
489
+ bom_path = self._bom_path(bom)
490
+ plan_f = self.inside(self.rel(self._librarian_dir(bom_path) / "plan.json"))
491
+ if not plan_f.exists():
492
+ raise ValueError("no librarian plan for this BOM yet: run the librarian (Fill missing parts) first")
493
+ return bom_path, plan_f, json.loads(plan_f.read_text(encoding="utf-8"))
494
+
495
+ @staticmethod
496
+ def _part(items, part):
497
+ if not isinstance(part, str) or not part.strip():
498
+ raise ValueError("no part given")
499
+ it = next((i for i in items if i["name"] == part), None)
500
+ if it is None:
501
+ raise ValueError(f"{part!r} is not a part of this plan")
502
+ return it
503
+
504
+ def _resolve_sources(self):
505
+ from . import resolve as R
506
+ return R.Sources(self.root / "http_cache", self.libs + [str(self.root / "footprints")], self.offline)
507
+
508
+ @staticmethod
509
+ def _kind(kind):
510
+ from . import resolve as R
511
+ if kind not in R.KINDS:
512
+ raise ValueError(f"kind must be one of {', '.join(R.KINDS)}")
513
+ return kind
514
+
515
+ def _bom_label(self, bom_path):
516
+ return self.rel(bom_path) if self._is_inside(bom_path) else str(bom_path)
517
+
518
+ def held_parts(self, bom):
519
+ """Held plan items: name, folder, missing halves, hold reasons and where each half came from. No network."""
520
+ from . import resolve as R
521
+ bom_path, plan_f, _ = self._plan(bom)
522
+ from . import messages as M # each reason also as a plain sentence + next step (code kept)
523
+ held = [dict(h, reasons=[{k: e[k] for k in ("code", "text", "fix", "note")} for e in M.explain_all(h["hold_reasons"])])
524
+ for h in R.held(plan_f)]
525
+ return {"bom": self._bom_label(bom_path), "plan": self.rel(plan_f), "held": held}
526
+
527
+ def part_candidates(self, bom, part, kind=None, limit=10, progress=None):
528
+ """Ranked symbol / footprint candidates for one plan item (default: its missing halves, else both), with the
529
+ item's current state. May fetch KiCad library files (through the HTTP cache; offline uses the cache only)."""
530
+ from . import resolve as R
531
+ _, plan_f, items = self._plan(bom)
532
+ it = self._part(items, part)
533
+ kinds = [self._kind(kind)] if kind else (R.missing(it) or list(R.KINDS))
534
+ try:
535
+ limit = max(1, min(50, int(limit or 10)))
536
+ except (TypeError, ValueError):
537
+ raise ValueError("limit must be a number") from None
538
+ src, out = self._resolve_sources(), {}
539
+ for n, k in enumerate(kinds):
540
+ if progress:
541
+ progress(n, len(kinds), f"{k} candidates for {part}")
542
+ out[k] = R.candidates(it, k, plan_f.parent, src, limit)
543
+ if progress:
544
+ progress(len(kinds), len(kinds), "done")
545
+ return {"part": part, "item": R.show(plan_f, part), "candidates": out}
546
+
547
+ _CID = re.compile(r"(kicad|local):[^:/\\\0]+:[^:/\\\0]+|spec:[^:/\\\0]+")
548
+
549
+ def choose_part(self, bom, part, kind, candidate_id, pin_map=None):
550
+ """Use a candidate (kicad:Lib:Name, local:Lib:Name or spec:Name) for one half of a plan item, convert it into
551
+ the librarian's models folder, then re-run the release check and rewrite the review / release-check reports.
552
+ pin_map {symbol pin: pad} remaps pin numbers. Returns the librarian summary plus `chosen`."""
553
+ with self._plan_lock():
554
+ return self._choose_part(bom, part, kind, candidate_id, pin_map)
555
+
556
+ def _choose_part(self, bom, part, kind, candidate_id, pin_map=None):
557
+ from . import resolve as R
558
+ _, plan_f, items = self._plan(bom)
559
+ self._part(items, part)
560
+ self._kind(kind)
561
+ if not isinstance(candidate_id, str) or not self._CID.fullmatch(candidate_id) or \
562
+ any(s.strip() in ("", ".", "..") for s in candidate_id.split(":")):
563
+ raise ValueError(f"candidate id {candidate_id!r} must look like kicad:Lib:Name, local:Lib:Name or spec:Name")
564
+ if pin_map is not None and not (isinstance(pin_map, dict) and all(
565
+ isinstance(a, str) and isinstance(b, str) and a and b and len(a) < 40 and len(b) < 40 for a, b in pin_map.items())):
566
+ raise ValueError("pin_map must map symbol pin numbers to pad numbers, e.g. {\"S1\": \"SH\"}")
567
+ try:
568
+ chosen = R.choose(plan_f, part, kind, candidate_id, src=self._resolve_sources(), pin_map=pin_map or None)
569
+ except FileNotFoundError as e:
570
+ raise ValueError(str(e)) from e
571
+ out = self._release_check(bom) # the whole plan again (lock already held): reports, statuses, approvals
572
+ out["chosen"] = chosen
573
+ return out
574
+
575
+ # ------------------------------------------------------------------ sign-off (ecadlib.approvals)
576
+ def approval_status(self, bom):
577
+ """The sign-off policy next to the plan (none = approvals are recorded, not enforced) and one row per part."""
578
+ from . import approvals as AP
579
+ bom_path, plan_f, items = self._plan(bom)
580
+ base = plan_f.parent
581
+ policy = AP.load_policy(base)
582
+ pf, audit = AP.policy_file(base), base / AP.AUDIT
583
+ return {"bom": self._bom_label(bom_path), "policy": policy, "policy_file": self.rel(pf) if pf else None,
584
+ "rows": AP.status_rows(items, policy, base),
585
+ "awaiting_approval": sum(i.get("status") == "awaiting_approval" for i in items),
586
+ "audit": self.rel(audit) if audit.exists() else None}
587
+
588
+ def approve_part(self, bom, part, decision, user, role=None, comment="", via="ecadlib ui/mcp"):
589
+ """Record an approve / reject (see _approve_part). Holds <workspace>/agent/plan.lock while it rewrites the plan:
590
+ while the library agent's librarian rewrites a plan the decision is refused ("decide again in a minute")
591
+ instead of being overwritten when the librarian saves its copy."""
592
+ from .agent_queue import plan_lock
593
+ with plan_lock(self.root, wait=False) as free:
594
+ if not free:
595
+ raise ValueError("the library agent is rebuilding the library right now; decide again in a minute")
596
+ return self._approve_part(bom, part, decision, user, role, comment, via)
597
+
598
+ def _approve_part(self, bom, part, decision, user, role=None, comment="", via="ecadlib ui/mcp"):
599
+ """Record an approve / reject for one part as `user`. user is required: locally there is no login, so the
600
+ caller says who it is (never taken from this machine's git identity); the team server passes the signed-in
601
+ user and via="ecadlib ui (team, signed in)", which the audit entry records. Every decision, refused ones included, is
602
+ appended to approvals_audit.jsonl; the part then flips between ready and awaiting_approval."""
603
+ from . import approvals as AP, librarian as L
604
+ if decision not in ("approve", "reject"):
605
+ raise ValueError("decision must be approve or reject")
606
+ user = user.strip() if isinstance(user, str) else ""
607
+ if not user:
608
+ raise ValueError("say who you are: user is required (e.g. \"Ann Lee <ann@example.com>\")")
609
+ if len(user) > 200 or any(c in user for c in "\r\n\0"):
610
+ raise ValueError("user must be one line of at most 200 characters")
611
+ role = (role.strip() if isinstance(role, str) else "") or None
612
+ comment = comment if isinstance(comment, str) else ""
613
+ bom_path, plan_f, items = self._plan(bom)
614
+ it, base = self._part(items, part), plan_f.parent
615
+ policy = AP.load_policy(base)
616
+ entry = {"ts": AP.now(), "plan": str(plan_f), "part": part, "who": user, "role": role or "", "decision": decision,
617
+ "comment": comment, "via": via}
618
+ try:
619
+ rec = AP.decide(it, decision, user, role, comment, base, policy)
620
+ except AP.Refused as e:
621
+ entry["result"] = f"refused: {e}"
622
+ AP.audit(base, entry)
623
+ raise ValueError(str(e)) from e
624
+ status = AP.refresh_status(it, policy, base)
625
+ entry.update(role=rec["role"], hash=rec["hash"], result="recorded", status=status)
626
+ AP.audit(base, entry)
627
+ write_text_atomic(plan_f, json.dumps(items, indent=2))
628
+ L.write_review(L.review_rows(items), base, str(bom_path))
629
+ out = self._librarian_summary(bom_path, items, [])
630
+ out["decision"] = {"part": part, "decision": decision, "who": user, "role": rec["role"], "status": status,
631
+ "enforced": bool(policy)}
632
+ return out
633
+
634
+ # ------------------------------------------------------------------ library agent review queue (ecadlib.agent_queue)
635
+ def agent_queue_list(self, status=None, batch=None):
636
+ """Queue items (summaries, newest first), counts per state and batches; batch=ID adds that batch."""
637
+ from . import agent_queue as AQ
638
+ return AQ.ws_list(self.root, status, batch)
639
+
640
+ def agent_queue_get(self, item_id):
641
+ """One queue item in full, its showable pictures (paths inside agent/), allowed next states and its batch."""
642
+ from . import agent_queue as AQ
643
+ return AQ.ws_get(self.root, item_id)
644
+
645
+ def agent_queue_decide(self, item_id, action, user, role=None, note="", via="ecadlib ui/mcp", aliases=(), expect=None):
646
+ """approve / reject / rebuild / retry a queue item as `user`; approve and reject also go through approvals.py.
647
+ expect: the build_token agent_queue_get gave; the decision is refused if the item was rebuilt since."""
648
+ from . import agent_queue as AQ
649
+ return AQ.decide(self, item_id, action, user, role, note, via, aliases, expect=expect)
650
+
651
+ def agent_picture(self, path):
652
+ """Path of a picture under <workspace>/agent/ (anything else: ValueError)."""
653
+ from . import agent_queue as AQ
654
+ return AQ.picture_path(self.root, path)
655
+
656
+ # ------------------------------------------------------------------ duplicates (ecadlib.dedupe)
657
+ def find_duplicates(self, bom, merge=False):
658
+ """Duplicate parts (same MPN), footprints (same geometry) and symbols (same pinout) across the librarian plan
659
+ and the workspace's altium_models/ and footprints/. merge=True points duplicates at their keeper in the plan
660
+ (library files are never changed; plan.json.pre-dedupe keeps the old plan) and re-runs the release check."""
661
+ with self._plan_lock() if merge else contextlib.nullcontext():
662
+ return self._find_duplicates(bom, merge)
663
+
664
+ def _find_duplicates(self, bom, merge=False):
665
+ from . import dedupe as D
666
+ _, plan_f, _ = self._plan(bom)
667
+ libs = [p for p in (self.models, self.root / "footprints") if p.exists()]
668
+ r = D.run(plan_f, libs, do_merge=bool(merge), who="ecadlib ui/mcp")
669
+ keep = ("name", "origin", "mpn", "used_by", "status", "where", "files", "file")
670
+ short = lambda f: self.rel(f) if self._is_inside(f) else str(f)
671
+ groups = []
672
+ for g in r["groups"]:
673
+ members = []
674
+ for m in g["members"]:
675
+ m = {k: m[k] for k in keep if m.get(k) not in (None, "", [])}
676
+ if "files" in m:
677
+ m["files"] = [short(f) for f in m["files"]]
678
+ m.pop("file", None)
679
+ elif "file" in m:
680
+ m["file"] = short(m["file"])
681
+ members.append(m)
682
+ groups.append({"kind": g["kind"], "key": g["key"], "keeper": g["keeper"], "members": members})
683
+ out = {"groups": groups, "mapping": r["mapping"], "errors": r["errors"], "merged": bool(merge),
684
+ "reports": [self.rel(r["report"]), self.rel(r["map"])]}
685
+ if merge:
686
+ out["summary"] = self._release_check(bom) # the plan lock is already held
687
+ return out
688
+
689
+ # ------------------------------------------------------------------ upkeep (ecadlib.upkeep)
690
+ def upkeep_run(self, bom, checks=None, apply=False, workspace=None):
691
+ """The weekly upkeep checks for a BOM (its librarian plan when there is one): lifecycle, KiCad upstream, price
692
+ and stock, Altium 365 drift. Reports only what changed since the last upkeep run of this BOM; checks without
693
+ credentials are skipped. apply=True reconverts KiCad parts whose sources changed. Never touches GitHub."""
694
+ from . import upkeep as UK
695
+ bom_path = self._bom_path(bom)
696
+ base = self.root / "upkeep" / self._key(bom_path)
697
+ plan_f = self._librarian_dir(bom_path) / "plan.json"
698
+ src = plan_f if plan_f.exists() else bom_path
699
+ manifest = next((m for m in (plan_f.parent / "models" / "manifest.json", self.models / "manifest.json")
700
+ if m.is_file()), None)
701
+ rules = self.rules()
702
+ rep = UK.run(str(src), checks or None, base / "state", base / "upkeep", manifest and str(manifest),
703
+ str(bom_path) if src != bom_path else None, workspace or None, bool(apply), rules,
704
+ UK.Ctx(cache=self.root / "http_cache", offline=self.offline))
705
+ rel = lambda p: self.rel(p) if self._is_inside(p) else str(p)
706
+ return {"bom": self._bom_label(bom_path), "input": rel(src), "new_changes": rep["new_changes"],
707
+ "exit_code": rep["exit_code"],
708
+ "checks": [{k: s[k] for k in ("check", "status", "reason", "changes", "resolved", "notes")} for s in rep["checks"]],
709
+ "applied": [rel(p) for p in rep["applied_paths"]],
710
+ "reports": [self.rel(base / "upkeep.md"), self.rel(base / "upkeep.json")]}
711
+ # ------------------------------------------------------------------ library agent (ecadlib.agent)
712
+ def agent_status(self):
713
+ """What the library agent did last (status.json), its queue counts per state and its batches/PRs."""
714
+ from . import agent as AG
715
+ return AG.status(self.root)
716
+
717
+ def agent_run(self, dry_run=True):
718
+ """One agent pass now: scan watched sources, build and prove new parts, batch PR, push (if [agent] auto_push).
719
+ dry_run (the default) reports what would happen and changes nothing."""
720
+ from . import agent as AG
721
+ rep = AG.run_once(self.root, self.rules(), dry_run=bool(dry_run), libs=self.libs)
722
+ return {k: v for k, v in rep.items() if k != "push"} | {"push": rep.get("push")}
723
+
724
+ # ------------------------------------------------------------------ Altium projects (ecadlib.altium_project)
725
+ def project_bom(self, project, variant=None):
726
+ """BOM straight from an Altium .PrjPcb / .SchDoc (with variants and ActiveBOM choices); writes
727
+ reports/project_<name>.csv, which triage/librarian also accept, as does the project file itself."""
728
+ from . import altium_project as AP
729
+ p = self._bom_path(project)
730
+ prj = AP.load_project(p)
731
+ groups = AP.bom_groups(prj, variant)
732
+ variants = [v["name"] for v in prj["variants"]]
733
+ out = AP.write_bom_csv(groups, self.root / "reports" / f"project_{self._key(p)}{'_' + self.safe_name(variant) if variant else ''}.csv", variants)
734
+ keep = ("designator", "qty", "mpn", "manufacturer", "value", "footprint", "description", "libref", "design_item_id",
735
+ "dnp", "alternates", "variant_notes", "variants", "lifecycle", "item_guid", "revision_guid")
736
+ return {"project": prj["name"], "variant": variant, "variants": variants, "sheets": [Path(s).name for s in prj["sheets"]],
737
+ "components": len(prj["components"]), "lines": [{k: g[k] for k in keep} for g in groups],
738
+ "warnings": prj["warnings"], "csv": self.rel(out)}
739
+
740
+ def where_used(self, projects, part=None, plan=None):
741
+ """Which projects use which part. projects: folders or project files. The library compared against is the
742
+ given librarian plan (or every librarian plan in the workspace) plus altium_models/."""
743
+ from . import altium_project as AP
744
+ dirs = [self._bom_path(d) for d in ([projects] if isinstance(projects, str) else projects)]
745
+ plans = [self._bom_path(plan)] if plan else sorted((self.root / "librarian").glob("*/plan.json"))
746
+ if plan and not plans[0].is_file():
747
+ raise ValueError(f"plan not found: {plan} is not a plan.json file")
748
+ libs = [str(self.models)] if self.models.exists() else []
749
+ res = AP.where_used(dirs, part, plans[0] if plans else None, libs)
750
+ files = AP.write_where_used(res, self.root / "reports" / "where_used")
751
+ rows = AP.where_used_rows(res)
752
+ return {"projects": res["projects"], "flags": res["flags"], "rows": rows, "warnings": res["warnings"],
753
+ "library": [self.rel(p) if self._is_inside(p) else str(p) for p in plans[:1]] + [self.rel(l) for l in libs],
754
+ "reports": [self.rel(f) for f in files.values()]}
755
+ def sim_models(self, bom, attach=False, models=None):
756
+ """Find and check SPICE / IBIS models for the librarian plan's parts (built-in passives, the workspace's
757
+ sim_models/ folder plus `models` folders inside the workspace, Nexar documents, vendor URL patterns).
758
+ attach=True writes SIM links into the .SchLib files and Sim.* fields for kicad-export, and re-runs the
759
+ release check (a model whose pin map is unverified holds its part as `verify`)."""
760
+ from . import sim_models as SM
761
+ _, plan_f, _ = self._plan(bom)
762
+ dirs = [self.inside(m) for m in ([models] if isinstance(models, str) else models or [])]
763
+ own = self.root / "sim_models"
764
+ if own.is_dir():
765
+ dirs.insert(0, own)
766
+ rep = SM.run(plan_f, [d for d in dirs if d.is_dir()], attach=bool(attach),
767
+ http=CachedHTTP(self.root / "http_cache", offline=self.offline, timeout=60), rules=self.rules(), log=lambda *a: None)
768
+ return {"attach": rep["attach"], "parts": rep["parts"], "hosts": rep["hosts"], "altium365": rep["altium365"],
769
+ "errors": rep.get("errors", []), "reports": [self.rel(plan_f.parent / "sim_models.md"), self.rel(plan_f.parent / "sim_models.json")]}
770
+
771
+ def _datasheet_reader(self, out, batch=True, wait=False, estimate=False, yes=False):
772
+ from . import datasheet as D
773
+ return D.Reader(out, self.root / "http_cache", rules=self.rules(), batch=batch, wait=wait, estimate=estimate, yes=yes,
774
+ http=CachedHTTP(self.root / "http_cache", offline=self.offline, timeout=60), log=lambda *a: None)
775
+
776
+ def datasheet_batch(self, bom, sync=False, wait=False, parts=None, yes=False):
777
+ """Read the datasheet of every part of the librarian plan that still has no symbol or footprint, as one
778
+ Message Batch (sync=True: one Messages call per part), build them, mark them `verify` and re-run the release
779
+ check. wait=False submits and returns; call again to collect (the batch id is kept, nothing is paid twice).
780
+ With no plan yet, the librarian runs first with the batch as its datasheet step."""
781
+ from . import datasheet as D, librarian as L
782
+ bom_path = self._bom_path(bom)
783
+ out = self._librarian_dir(bom_path)
784
+ rd = self._datasheet_reader(out, batch=not sync, wait=wait, yes=yes)
785
+ if not rd.key:
786
+ raise ValueError(f"{D.settings(self.rules())['api_key_env']} is not set: the datasheet reader needs a Claude API key "
787
+ "(datasheet_estimate works without one)")
788
+ log = []
789
+ rd.say = lambda m: log.append(str(m).strip())
790
+ if (out / "plan.json").exists():
791
+ items = D.fill_plan(out / "plan.json", rd, self.rules(), log=log.append, parts=set(parts or []) or None)
792
+ else:
793
+ self._read_bom(bom_path)
794
+ provs = self._librarian_providers()
795
+ items = L.run(str(bom_path), self.libs + [str(self.root / "footprints")], out, None, None, False, [], self.offline,
796
+ self.root / "http_cache", 0.5, provs, http=rd.http, log=log.append, rules=self.rules(), reader=rd)
797
+ state = rd._state()
798
+ r = self._librarian_summary(bom_path, items, log)
799
+ r["batches"] = [{k: b.get(k) for k in ("id", "status", "counts", "collected", "errors")} for b in state["batches"]][-5:]
800
+ r["pending"] = sum(1 for b in state["batches"] if not b.get("collected"))
801
+ return r
802
+
803
+ def datasheet_estimate(self, bom):
804
+ """Tokens and cost of reading the datasheets the librarian plan still needs, before anything is sent (exact
805
+ input counts with an API key, rough without). Downloads the datasheets (cached) to count their pages."""
806
+ from . import datasheet as D
807
+ bom_path = self._bom_path(bom)
808
+ out = self._librarian_dir(bom_path)
809
+ plan_f = out / "plan.json"
810
+ if not plan_f.exists():
811
+ raise ValueError("no librarian plan for this BOM yet: run the librarian (Fill missing parts) first")
812
+ est = D.estimate_plan(plan_f, self._datasheet_reader(out, estimate=True))
813
+ return dict(est, key_set=est["exact_input"])
814
+
815
+ def cross_checks(self, bom):
816
+ """Which datasheet parts of the librarian plan were cross-checked against a second source (ecadlib.crosscheck)
817
+ and how: agreed, disagreed (held, both values in the review), unchecked or skipped. Sends nothing."""
818
+ import json
819
+ from . import crosscheck as CC
820
+ out = self._librarian_dir(self._bom_path(bom))
821
+ plan_f = out / "plan.json"
822
+ if not plan_f.exists():
823
+ raise ValueError("no librarian plan for this BOM yet: run the librarian (Fill missing parts) first")
824
+ parts = [{"part": it["name"], "status": it.get("status"), "cross_check": it["cross_check"]["status"],
825
+ "summary": it["cross_check"].get("summary", "")}
826
+ for it in json.loads(plan_f.read_text(encoding="utf-8")) if it.get("cross_check")]
827
+ counts = {s: sum(p["cross_check"] == s for p in parts) for s in ("agreed", "disagreed", "unchecked", "skipped")}
828
+ return {"parts": parts, "counts": counts, "modes": list(CC.MODES)}
829
+
830
+ def library_parts(self, bom, pictures=True):
831
+ """The librarian plan of a BOM as cards (ecadlib.partview): status in plain words, held reasons with a next
832
+ step, cross-check and KiCad/Altium results with both values, datasheet link, cost and model, a footprint
833
+ picture. Read-only, no network."""
834
+ from . import partview as PV
835
+ bom_path, plan_f, items = self._plan(bom)
836
+ reports = [self.rel(f) for f in (plan_f.parent / n for n in ("review.md", "release_check.md", "plan.json")) if f.exists()]
837
+ return dict(PV.cards(plan_f.parent, items, pictures), bom=self._bom_label(bom_path), plan=self.rel(plan_f), reports=reports)
838
+
839
+ # ------------------------------------------------------------------ library hub (ecadlib.hub)
840
+ def hub(self, q="", have=(), lack=(), states=(), origin="", flags=(), sort="name", page=1, size=100, force=False,
841
+ tool="", library="", project=""):
842
+ """Every part once across your libraries and ecadlib's own, with a cell per tool (missing / same / different /
843
+ behind), last updated and by whom. Filters as in hub.listing. Reads only; notes go to <workspace>/hub/."""
844
+ from . import hub as H
845
+ bad = [t for t in [*have, *lack, *([tool] if tool else [])] if t not in H.LABEL] + [s for s in states if s not in H.STATES]
846
+ if bad:
847
+ raise ValueError(f"unknown tool or state {bad[0]!r}: tools are {', '.join(H.LABEL)}; states {', '.join(H.STATES)}")
848
+ return H.listing(self, q, have, lack, states, origin, flags, sort, page, size, force, tool, library, project)
849
+
850
+ def hub_part(self, key):
851
+ """One hub part: each tool's copy (where, updated, by, what differs), footprint and symbol pictures, sources,
852
+ release check, sign-off, where-used, lifecycle, history and the fixes on offer."""
853
+ from . import hub as H
854
+ return H.part(self, key)
855
+
856
+ def hub_fix(self, key, action, who=None):
857
+ """Run one of a hub part's fixes: kicad (write the KiCad library of its BOM), a365_preview (Altium 365 dry run),
858
+ dedupe (find duplicates in its plan), tool:<id> (another tool's exporter). "build" is hub_build_bom + the
859
+ librarian; "open_plan" happens in the browser."""
860
+ from . import hub as H
861
+ row, copies = H.copies_of(self, key)
862
+ offered = {f["action"] for f in H.fixes(row, copies)}
863
+ if action not in offered:
864
+ raise ValueError(f"{action!r} is not on offer for {row['name']}: {', '.join(sorted(offered)) or 'nothing to fix'}")
865
+ if action.startswith("copy:"):
866
+ return {"action": action, "part": row["name"], "result": H.copy_parts(self, [key], action[5:], "", who)}
867
+ pc = next(c for c in copies if c["plan"] and c["origin"] == "ecadlib" and c["bom"])
868
+ if action == "kicad":
869
+ out = self.kicad_dblib(pc["bom"])
870
+ what = "made the KiCad version"
871
+ elif action == "a365_preview":
872
+ r = self.altium365_push(pc["bom"], None, dry_run=True)
873
+ out = dict(r, results=[x for x in r["results"] if x.get("name") == pc["name"]])
874
+ what = None
875
+ elif action == "dedupe":
876
+ out, what = self.find_duplicates(pc["bom"]), None
877
+ elif action.startswith("tool:"):
878
+ reg = H._READERS[action[5:]]
879
+ items = json.loads(self.inside(pc["plan"]).read_text(encoding="utf-8"))
880
+ out = reg["fix"](self, self.inside(pc["plan"]), items[pc["item"]],
881
+ **({"bom": pc["bom"]} if getattr(reg["fix"], "wants_bom", False) else {}))
882
+ what = f"made the {reg['label']} version"
883
+ else:
884
+ raise ValueError(f"{action!r} runs from the web page, not here")
885
+ if what:
886
+ H.record(self.root, [{"when": H.now(), "who": who, "what": what, "kind": "action", "key": key, "name": row["name"],
887
+ "bom": pc["bom"], "tool": action[5:] if action.startswith("tool:") else action}])
888
+ return {"action": action, "part": row["name"], "result": out}
889
+
890
+ def hub_copy(self, keys, tool, project="", who=None):
891
+ """Copy parts to another tool through the hub (Altium <-> KiCad), into <workspace>/hub/library; with a project
892
+ listed under [hub] projects, also import that library into the project. Your own library files are not edited."""
893
+ from . import hub as H
894
+ return H.copy_parts(self, keys, tool, project, who)
895
+
896
+ def hub_build_bom(self, key):
897
+ """A one-line BOM for a part that is only in your libraries (for "Build it with ecadlib") -> its workspace path."""
898
+ from . import hub as H
899
+ row, copies = H.copies_of(self, key)
900
+ if not row["mpn"]:
901
+ raise ValueError(f"{row['name']} has no manufacturer part number, so ecadlib can't look it up")
902
+ return self.save_upload(f"hub_{self.safe_name(row['mpn'])}.csv", H.one_line_bom(row, copies))
903
+
904
+ def demo_bom(self):
905
+ """Copy the bundled sample BOM (the one `ecadlib demo` uses) into uploads/demo/; -> its workspace path."""
906
+ import shutil
907
+ from .firstrun import DEMO_BOM
908
+ if self.root.resolve() == Path(self.root.resolve().anchor):
909
+ raise ValueError("the workspace is the filesystem root; start ecadlib ui with --workspace FOLDER")
910
+ dest = self.root / "uploads" / "demo" / DEMO_BOM.name
911
+ dest.parent.mkdir(parents=True, exist_ok=True)
912
+ if not dest.exists() or dest.read_bytes() != DEMO_BOM.read_bytes():
913
+ shutil.copyfile(DEMO_BOM, dest)
914
+ return self.rel(dest)
915
+
916
+ def altium365_workspaces(self):
917
+ from . import altium365 as A
918
+ return {"workspaces": A.Nexar(_nexar_token()).workspaces()}
919
+
920
+ def altium365_push(self, bom, workspace_url, dry_run=True):
921
+ """Release ready parts and update parameters of existing ones; parts the release check held are left
922
+ alone. dry_run (the default) sends nothing and returns the requests that would be made."""
923
+ from . import altium365 as A
924
+ if not workspace_url and not dry_run:
925
+ raise ValueError("choose an Altium 365 workspace first")
926
+ plan = self.altium365_plan(bom)
927
+ client = None if dry_run else A.Nexar(_nexar_token())
928
+ policy = None
929
+ if plan["source"] == "librarian": # the sign-off policy as it is now, not as at the last check
930
+ from .approvals import load_policy
931
+ policy = load_policy(self.inside(plan["plan"]).parent)
932
+ results = A.push(plan["items"], client, workspace_url or "https://<workspace>", dry_run=dry_run, policy=policy,
933
+ rules=self.rules())
934
+ if any(r.get("a365_updated") for r in results):
935
+ from .a365_sync import save_meta # new revisions of pulled parts, so the next push is not a conflict
936
+ save_meta(self.inside(plan["plan"]), plan["items"])
937
+ out = self.root / "altium365" / f"push_{'dry_run_' if dry_run else ''}{self._key(self._bom_path(bom))}.json"
938
+ out.write_text(json.dumps(results, indent=2), encoding="utf-8")
939
+ return {"dry_run": dry_run, "results": results, "report": self.rel(out)}
940
+
941
+ # ------------------------------------------------------------------ two-way sync (ecadlib.a365_sync)
942
+ def _a365_plan(self, bom=None):
943
+ """The plan a pull writes into: the librarian plan of a BOM (so the librarian reuses workspace parts), or
944
+ altium365/workspace_plan.json for the whole workspace library."""
945
+ if bom:
946
+ f = self._librarian_dir(self._bom_path(bom)) / "plan.json"
947
+ f.parent.mkdir(parents=True, exist_ok=True)
948
+ return self.inside(self.rel(f))
949
+ return self.root / "altium365" / "workspace_plan.json"
950
+
951
+ def _a365_run(self, mode, workspace_url, bom=None, folders=None, client=None):
952
+ from . import a365_sync as S, altium365 as A
953
+ if not workspace_url:
954
+ raise ValueError("choose an Altium 365 workspace first")
955
+ r = S.run(mode, self._a365_plan(bom), client or A.Nexar(_nexar_token()), workspace_url,
956
+ folders=folders or None, rules=self.rules(), log=lambda *_: None)
957
+ return {"mode": mode, "summary": r["summary"], "conflicts": r["conflicts"], "unresolved": r["unresolved"],
958
+ "rows": [x for x in r["rows"] if x["status"] != "in_sync"], "components": len(r["components"]),
959
+ "plan": self.rel(r["plan"]), "state": self.rel(r["state"]) if mode != "status" else None,
960
+ "report": self.rel(r["report"])}
961
+
962
+ def a365_pull(self, workspace_url, bom=None, folders=None, client=None):
963
+ """Import the workspace's components into a plan (status `existing`), refresh ones pulled before, download
964
+ their .SchLib/.PcbLib. Changes local files only; conflicts are reported, not settled."""
965
+ return self._a365_run("pull", workspace_url, bom, folders, client)
966
+
967
+ def a365_status(self, workspace_url, bom=None, client=None):
968
+ """Read-only comparison of the plan with the workspace (new, changed on either side, conflicts, deleted)."""
969
+ return self._a365_run("status", workspace_url, bom, None, client)
970
+
971
+ # ------------------------------------------------------------------ library agent: watch (ecadlib.agent_watch)
972
+ def agent_watch_scan(self, sources=None, dry_run=False, client=None):
973
+ """New / changed / removed parts in the watched projects and BOMs ([agent.watch] sources, or `sources`), compared
974
+ with the last scan (agent/watch_state.json; dry_run leaves it alone). Relative refs are workspace-relative."""
975
+ from . import agent_watch as AW
976
+ rep = AW.scan_report(self, sources=sources, client=client, dry_run=dry_run)
977
+ rep["state"] = self.rel(rep["state"])
978
+ return rep
979
+
980
+ # ------------------------------------------------------------------ library rules (ecadlib.rules / rules_infer)
981
+ def rules_file(self):
982
+ """<workspace>/ecadlib.toml (or ecadlib_rules.json when only that exists)."""
983
+ return next((self.root / n for n in RU.FILES if (self.root / n).is_file()), self.root / RU.FILES[0])
984
+
985
+ def rules_in_use(self):
986
+ """The rules file the workspace follows: its own, else the one every ecadlib command run here finds (the
987
+ workspace's parents, then the current folder and its parents: `ecadlib init` writes ./ecadlib.toml). The
988
+ tools the workspace runs by path (DbLib export, approvals, upkeep) search the same way, so all agree. None: no file."""
989
+ f = self.rules_file()
990
+ return f if f.is_file() else RU.find(self.root)
991
+
992
+ def _label(self, p):
993
+ return self.rel(p) if self._is_inside(p) else str(p)
994
+
995
+ def rules(self):
996
+ """The rules in use (rules_in_use) merged over the defaults (no file: ecadlib's own behaviour)."""
997
+ f = self.rules_in_use()
998
+ return RU.load(f) if f else RU.load({})
999
+
1000
+ def rules_info(self):
1001
+ f = self.rules_in_use()
1002
+ return {"file": self._label(f or self.rules_file()), "exists": bool(f), "text": f.read_text(encoding="utf-8") if f else "",
1003
+ "rules": RU.effective(self.rules()), "defaults": RU.DEFAULTS}
1004
+
1005
+ def _rules_sources(self):
1006
+ """What a scan looks at when the person names nothing: the --lib folders, uploaded BOMs and Altium 365 state."""
1007
+ out = list(self.libs) + [str(self.root / "uploads")]
1008
+ out += [str(p) for p in sorted(self.root.glob("**/a365_state.json")) if self._is_inside(p)]
1009
+ return out
1010
+
1011
+ def propose_rules(self, paths=None, a365=None, choices=None, progress=None):
1012
+ """Scan existing libraries (Altium .SchLib/.PcbLib/.DbLib, parts tables, KiCad libraries, BOMs, Altium 365
1013
+ component lists) and propose rules with evidence and a confidence per setting. Nothing is written; the
1014
+ proposal's `toml` goes to save_rules. paths default to the workspace's --lib folders and uploaded BOMs.
1015
+ choices {setting key: value} answer the proposal's questions. A diff is included when a file exists."""
1016
+ from . import rules_infer as I
1017
+ paths, a365 = ([x] if isinstance(x, (str, Path)) else x for x in (paths, a365)) # one path, not its letters
1018
+ paths = [str(p).strip() for p in (paths or []) if str(p).strip()] or self._rules_sources()
1019
+ if progress:
1020
+ progress(0, 1, "scanning " + ", ".join(Path(p).name for p in paths)[:80])
1021
+ out = I.propose(paths, [str(x) for x in (a365 or [])], choices, existing=self.rules_file())
1022
+ out["paths"], out["file"] = paths, self.rel(self.rules_file())
1023
+ if progress:
1024
+ progress(1, 1, "done")
1025
+ return out
1026
+
1027
+ def save_rules(self, text, overwrite=False):
1028
+ """Validate and write <workspace>/ecadlib.toml. An existing different file is only replaced with overwrite=True
1029
+ (kept as ecadlib.toml.bak); otherwise the diff comes back with needs_confirm, for the person to look at."""
1030
+ from . import rules_infer as I
1031
+ if not isinstance(text, str) or not text.strip():
1032
+ raise ValueError("no rules text given")
1033
+ res = I.save(text, self.rules_file(), bool(overwrite))
1034
+ res["path"] = self.rel(self.rules_file())
1035
+ if res.get("backup"):
1036
+ res["backup"] = self.rel(res["backup"])
1037
+ return res
1038
+
1039
+ # ------------------------------------------------------------------ house style (ecadlib.house_style)
1040
+ def _style_file(self):
1041
+ from . import house_style as HS
1042
+ return HS.profile_path(self.rules(), self.root)
1043
+
1044
+ def house_style(self):
1045
+ """The learned house style: settings, description patterns, value formats, pin notation, the person's
1046
+ direction and the model's guide. {exists: False} before `learn_house_style`."""
1047
+ from . import house_style as HS
1048
+ f = self._style_file()
1049
+ prof = HS.load(self.rules(), path=f) if f.is_file() else None
1050
+ if not prof:
1051
+ return {"exists": False, "file": self._label(f)}
1052
+ return {"exists": True, "file": self._label(f), "summary": HS.summary(prof),
1053
+ **{k: prof.get(k) for k in ("learned_at", "learned_from", "components", "categories", "settings", "descriptions",
1054
+ "param_formats", "param_presence", "pins", "direction", "guide")}}
1055
+
1056
+ def learn_house_style(self, paths=None, direction=None, ai=False, model=None, progress=None):
1057
+ """Learn the house style from the person's library (paths default to the --lib folders), keep their direction
1058
+ (a line or a list of lines, added to what is there), and with ai=True have the configured model write the
1059
+ guide and the settings the direction implies. Writes <workspace>/house_style.json; ecadlib.toml is untouched."""
1060
+ from . import house_style as HS
1061
+ paths = [paths] if isinstance(paths, (str, Path)) else paths
1062
+ paths = [str(x).strip() for x in (paths or []) if str(x).strip()] or self._rules_sources()
1063
+ direction = [direction] if isinstance(direction, str) else list(direction or [])
1064
+ f = self._style_file()
1065
+ old = HS.load(self.rules(), path=f) if f.is_file() else None
1066
+ if progress:
1067
+ progress(0, 2 if ai else 1, "scanning " + ", ".join(Path(x).name for x in paths)[:80])
1068
+ prof = HS.learn(paths, None, direction, old)
1069
+ prof["_file"] = str(f)
1070
+ if ai:
1071
+ if progress:
1072
+ progress(1, 2, "the model is writing the guide")
1073
+ HS.write_guide(prof, self.rules(), model)
1074
+ HS.save(prof, f)
1075
+ return self.house_style()
1076
+
1077
+ def style_direction(self, text=None, clear=False):
1078
+ """Add a line of direction (what the person wants new parts to look like); clear=True removes the old ones."""
1079
+ from . import house_style as HS
1080
+ f = self._style_file()
1081
+ prof = (HS.load(self.rules(), path=f) if f.is_file() else None) or {"schema_version": HS.SCHEMA_VERSION,
1082
+ "direction": [], "learned_from": []}
1083
+ if clear:
1084
+ prof["direction"] = []
1085
+ if text:
1086
+ HS.add_direction(prof, text)
1087
+ HS.save(prof, f)
1088
+ return {"file": self._label(f), "direction": [d["text"] for d in prof.get("direction") or []]}
1089
+
1090
+ def check_house_style(self, bom, part=None):
1091
+ """How far each part of a BOM's plan is from the house style: per part, the findings code can fix and the
1092
+ ones a person has to (a missing house parameter, a name pattern)."""
1093
+ from . import house_style as HS
1094
+ _, _, items = self._plan(bom)
1095
+ prof = self._style_or_raise()
1096
+ return {it["name"]: HS.check_item(it, prof, self.rules()) for it in items if not part or it["name"] == part}
1097
+
1098
+ def apply_house_style(self, bom, ai=False, model=None, dry_run=True, part=None):
1099
+ """Adapt a BOM's plan to the house style: exact fixes by code, and with ai=True rewording by the model
1100
+ (checked: no new numbers, no identity fields). dry_run (default) shows the changes and writes nothing."""
1101
+ from . import house_style as HS
1102
+ prof = self._style_or_raise()
1103
+ with self._plan_lock():
1104
+ _, plan_f, items = self._plan(bom)
1105
+ only = {part} if part else None
1106
+ done = HS.adapt_items(items, prof, self.rules(), only)
1107
+ rejected = []
1108
+ if ai:
1109
+ more, rejected = HS.adapt_with_model(items, prof, self.rules(), model, only=only)
1110
+ for k, v in more.items():
1111
+ done.setdefault(k, []).extend(v)
1112
+ if done and not dry_run:
1113
+ bak = plan_f.with_name(plan_f.name + ".style.bak")
1114
+ if not bak.exists():
1115
+ bak.write_text(plan_f.read_text(encoding="utf-8"), encoding="utf-8")
1116
+ from .fsutil import write_text_atomic
1117
+ write_text_atomic(plan_f, json.dumps(items, indent=2))
1118
+ return {"changes": done, "rejected": rejected, "written": bool(done) and not dry_run, "plan": self.rel(plan_f)}
1119
+
1120
+ def _style_or_raise(self):
1121
+ from . import house_style as HS
1122
+ f = self._style_file()
1123
+ prof = HS.load(self.rules(), path=f) if f.is_file() else None
1124
+ if not prof:
1125
+ raise ValueError("no house style yet: learn it from the library first (learn_house_style)")
1126
+ return prof
1127
+
1128
+ def file_formats(self, migrate=False):
1129
+ """Versioned files in this workspace (ecadlib/migrate.py): kind, version, ok / old / new. migrate=True brings
1130
+ old ones to this version (backups in .ecadlib_backup/ next to them); files from a newer ecadlib are refused."""
1131
+ from . import migrate as MG
1132
+ rows = MG.migrate_tree(self.root, dry_run=not migrate, log=lambda *a: None)
1133
+ for r in rows:
1134
+ r["path"] = self.rel(Path(r["path"]))
1135
+ return {"rows": rows, "old": sum(r["state"] == "old" for r in rows),
1136
+ "newer": sum(r["state"] in ("new", "error") for r in rows), "migrated": bool(migrate)}
1137
+
1138
+ def rebuild_check(self, bom):
1139
+ """Rebuild the BOM's library from its library.lock.json offline in a temporary copy (ecadlib/rebuild.py) and
1140
+ report per part and file: identical, identical after normalising dates, or drift; and inputs that changed."""
1141
+ from . import rebuild as RB
1142
+ d = self._librarian_dir(self._bom_path(bom))
1143
+ if not (d / RB.LOCK).is_file():
1144
+ raise ValueError("no library.lock.json for this BOM yet: run the librarian (Fill missing parts) first")
1145
+ rep = RB.check(d, log=lambda *a: None)
1146
+ rep["lock"] = self.rel(Path(rep["lock"]))
1147
+ return rep
1148
+
1149
+ def visual_check(self, target, datasheet=None, pages=None, symbol=None, render_only=False, client=None):
1150
+ """Claude visual proof (ecadlib.visual_check): render our footprint / symbol (TOP VIEW, pin 1, pitch, grid)
1151
+ next to the datasheet pages and ask Claude whether they match. target: a part name in this workspace
1152
+ (approved footprint, librarian plan) or a workspace path to a .kicad_mod / package spec .json. Writes the
1153
+ pictures and proof.json under agent/proofs/<part>/. No API key -> verdict "skipped" (pictures still written)."""
1154
+ from . import visual_check as VC
1155
+ item = {"datasheet": datasheet, "pages": pages, "symbol": None}
1156
+ try:
1157
+ p = self.inside(target)
1158
+ except ValueError:
1159
+ p = None
1160
+ if p is not None and p.is_file():
1161
+ item.update(footprint=str(p), part=p.stem)
1162
+ else:
1163
+ found = VC._find_part(str(target), self.root)
1164
+ if not found:
1165
+ raise ValueError(f"{target}: not a footprint file or a part in this workspace")
1166
+ item.update({k: v for k, v in found.items() if not item.get(k)}, part=str(target))
1167
+ if symbol:
1168
+ item["symbol"] = str(self.inside(symbol))
1169
+ if datasheet and not str(datasheet).startswith("http"):
1170
+ item["datasheet"] = str(self.inside(datasheet))
1171
+ out = self.root / "agent" / "proofs" / self.safe_name(item["part"])
1172
+ pr = VC.prove(item, self.root, self.rules(), client=client, out_dir=out, render_only=render_only)
1173
+ pr["proof_file"] = self.rel(VC.write_proof(pr, out / "proof.json"))
1174
+ pr["images"] = {k: [self.rel(f) for f in v] for k, v in pr["images"].items()}
1175
+ return pr
1176
+
1177
+ def export_parts(self, bom, fmt="csv", include_held=False, name="ecadlib"):
1178
+ """The library's parts table (stable columns, ecadlib.parts_export) for a BOM's plan, as reports/parts_<bom>.<fmt>."""
1179
+ from . import parts_export as PE
1180
+ if fmt not in ("csv", "json", "xlsx"):
1181
+ raise ValueError(f"format must be csv, json or xlsx, not {str(fmt)[:20]!r}")
1182
+ bom_path, plan_f, _ = self._plan(bom)
1183
+ rows = PE.rows(plan_f, name, include_held)
1184
+ out = PE.write(rows, self.root / "reports" / f"parts_{self._key(bom_path)}.{fmt}", fmt)
1185
+ return {"file": self.rel(out), "parts": len(rows), "columns": PE.COLUMNS}
1186
+
1187
+ def kicad_dblib(self, bom, name="ecadlib", driver=None, include_held=False):
1188
+ """KiCad files (kicad-export) plus a KiCad database library (.kicad_dbl + SQLite) for a BOM's plan, in kicad/<bom>/."""
1189
+ from . import kicad_dblib as KD
1190
+ from .kicad_export import kname
1191
+ bom_path, plan_f, _ = self._plan(bom)
1192
+ out = self.root / "kicad" / self._key(bom_path)
1193
+ rep = KD.build(plan_f, out, kname(str(name))[:60], include_held, self.rules_in_use(), driver or KD.DEFAULT_DRIVER,
1194
+ export=True, http=CachedHTTP(self.root / "http_cache", offline=self.offline), log=lambda *a: None)
1195
+ return {"folder": self.rel(out), "files": [self.rel(p) for p in sorted(out.iterdir()) if p.is_file()],
1196
+ "tables": rep["tables"], "skipped": rep["skipped"], "warnings": rep["warnings"],
1197
+ "kicad_export": rep.get("kicad_export")}
1198
+
1199
+ def tool_export(self, bom, tool, name="ecadlib", include_held=False):
1200
+ """The BOM's library for another tool, written natively and read back pad by pad: tool "eagle" (Autodesk Fusion
1201
+ Electronics / EAGLE .lbr, in fusion/<bom>/) or "easyeda" (EasyEDA Pro .elibz, in easyeda/<bom>/). "bundle" is
1202
+ one zip to hand over (library, STEP models, INSTALL.md with the import steps and the model of each footprint)."""
1203
+ from .kicad_export import kname
1204
+ mods = {"eagle": ("eagle_export", "fusion"), "easyeda": ("easyeda_pro_export", "easyeda")}
1205
+ if tool not in mods:
1206
+ raise ValueError(f"tool must be eagle or easyeda, not {str(tool)[:20]!r}")
1207
+ import importlib
1208
+ mod = importlib.import_module(f"ecadlib.{mods[tool][0]}")
1209
+ bom_path, plan_f, _ = self._plan(bom)
1210
+ out = self.root / mods[tool][1] / self._key(bom_path)
1211
+ rep = mod.export(plan_f, out, kname(str(name))[:60], include_held, http=CachedHTTP(self.root / "http_cache", offline=self.offline),
1212
+ log=lambda *a: None, rules=self.rules_in_use())
1213
+ return {"folder": self.rel(out), "file": self.rel(out / rep["file"]), "bundle": self.rel(out / rep["bundle"]),
1214
+ "install": self.rel(out / rep["install"]), "counts": rep["counts"],
1215
+ "ok": not rep["errors"], "errors": rep["errors"][:30], "approximations": rep["warnings"][:30],
1216
+ "models": len(rep["models"]), "kicad_reread": rep.get("kicad_reread"),
1217
+ "report": self.rel(out / ("eagle_export.json" if tool == "eagle" else "easyeda_export.json"))}
1218
+
1219
+ def parity(self, bom, kicad=False, name="ecadlib"):
1220
+ """KiCad vs Altium parity of a BOM's library (ecadlib.parity): the plan's .kicad_mod against its .PcbLib / .SchLib,
1221
+ or with kicad=True the KiCad export in kicad/<bom>/ (run kicad_dblib first) against the Altium files, its
1222
+ .kicad_dbl rows included. Report in reports/parity_<bom>.md / .json. Never touches the network."""
1223
+ from . import parity as PY
1224
+ from .kicad_export import kname
1225
+ bom_path, plan_f, _ = self._plan(bom)
1226
+ kdir = None
1227
+ if kicad:
1228
+ kdir = self.root / "kicad" / self._key(bom_path)
1229
+ if not (kdir / f"{kname(str(name))[:60]}.kicad_sym").is_file():
1230
+ raise ValueError(f"no KiCad export in {self.rel(kdir)} yet: run kicad_dblib (or kicad-export) first")
1231
+ rep = PY.check_plan(plan_f, kdir, kname(str(name))[:60] if kdir else None, rules=self.rules_in_use())
1232
+ stem = PY.write_report(rep, self.root / "reports" / f"parity_{self._key(bom_path)}")
1233
+ bad = [p for p in rep["parts"] if p["result"] == "mismatch"]
1234
+ return {"mode": rep["mode"], "ok": rep["ok"], "counts": rep["counts"], "report": self.rel(stem.with_suffix(".md")),
1235
+ "mismatches": {p["part"]: [d["msg"] for d in p["diffs"] if d["level"] == "error"][:20] for p in bad},
1236
+ "database": [d["msg"] for d in rep["database"]][:50]}
1237
+
1238
+ def tool_packages(self, bom, name="ecadlib", kicad=8, include_held=False):
1239
+ """Packages that plug into the tools themselves, for a BOM's plan, in packages/<bom>/: a KiCad Plugin and
1240
+ Content Manager zip (kicad_pcm) and the Altium hand-off folder (altium_pkg: .LibPkg, install script, DbLib)."""
1241
+ from . import altium_pkg as AP, kicad_pcm as KP
1242
+ from .kicad_export import kname
1243
+ bom_path, plan_f, _ = self._plan(bom)
1244
+ lib = kname(str(name))[:60]
1245
+ out = self.root / "packages" / self._key(bom_path)
1246
+ pcm = KP.build(plan_f, out / "kicad", lib, kicad=int(kicad), include_held=include_held,
1247
+ http=CachedHTTP(self.root / "http_cache", offline=self.offline), log=lambda *a: None)
1248
+ alt = AP.build(plan_f, out / "altium", lib, rules=self.rules_in_use(), log=lambda *a: None)
1249
+ return {"folder": self.rel(out),
1250
+ "kicad": {"zip": self.rel(Path(pcm["zip"])), "version": pcm["version"], "nickname": pcm["nickname"],
1251
+ "symbols": pcm["symbols"], "footprints": pcm["footprints"], "problems": pcm["problems"]},
1252
+ "altium": {"folder": self.rel(out / "altium"), "libpkg": alt["libpkg"], "script": alt["script"],
1253
+ "dblibs": alt["dblibs"], "documents": len(alt["documents"]), "problems": alt["problems"]}}
1254
+
1255
+ def altium_menu(self, win_dir=None, name="ecadlib", command="", port=8765):
1256
+ """The "ecadlib" menu for Altium (ecadlib.altium_menu) in altium_menu/: a DelphiScript project whose entries
1257
+ add a part from its MPN, check the focused project's BOM and open the review queue, all in this workspace
1258
+ (settings use relative paths: workspace=.., library=..\\altium_library). win_dir: the Windows folder the
1259
+ altium_menu folder is in on the Altium PC, for the ready-made menu file (.DXPPrf)."""
1260
+ from . import altium_menu as AM
1261
+ out = self.root / "altium_menu"
1262
+ rep = AM.build(out, "..", "..\\altium_library", name, command, win_dir or None, int(port))
1263
+ return {"folder": self.rel(out), "files": [self.rel(p) for p in sorted(out.iterdir()) if p.is_file()],
1264
+ "written": rep["written"], "kept": rep["kept"], "menu_file": rep["menu_file"], "problems": rep["problems"],
1265
+ "procedures": rep["procedures"]}
1266
+
1267
+ def example_specs(self):
1268
+ d = Path(__file__).resolve().parent / "package_specs" # shipped as package data
1269
+ return {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted(d.glob("*.json"))} if d.exists() else {}
1270
+
1271
+ # ------------------------------------------------------------------ setup check and summary (ecadlib.doctor / ws_status)
1272
+ def status(self):
1273
+ """One-screen summary (read-only): parts per librarian plan, held, waiting for sign-off, last triage, last
1274
+ upkeep, the library agent's state and queue counts. Paths are relative to the workspace."""
1275
+ from . import ws_status
1276
+ return ws_status.status(self.root)
1277
+
1278
+ def doctor(self):
1279
+ """The `ecadlib doctor` checks for this workspace and its --lib folders: each {check, level OK/WARN/FAIL,
1280
+ detail, hint}. API keys are reported by name only."""
1281
+ from . import doctor
1282
+ rf = self.rules_in_use()
1283
+ return doctor.run(self.root, self.libs, str(rf) if rf else None)
1284
+
1285
+ # ------------------------------------------------------------------ plugins (ecadlib.plugins), notifications
1286
+ def _provider_classes(self):
1287
+ """Triage sources: --providers when given, else [providers] order, else every built-in (today's default)."""
1288
+ from . import plugins as PL
1289
+ return PL.select(self.provider_names if self._providers_given else None, self.rules(),
1290
+ default=[c.name for c in P.ALL])
1291
+
1292
+ def _librarian_providers(self):
1293
+ """The librarian's sources: [providers] order when set (and no --providers), else the free built-in two."""
1294
+ from . import plugins as PL
1295
+ if not self._providers_given and PL.order_set(self.rules()):
1296
+ return None
1297
+ return ",".join(p for p in ("jlcpcb_search", "easyeda") if p in self.provider_names)
1298
+
1299
+ def plugins(self):
1300
+ """Installed provider / exporter plugins, the [providers] order in use and the [notify] targets (no secrets)."""
1301
+ from . import notify as N, plugins as PL
1302
+ d = PL.describe()
1303
+ r = self.rules()
1304
+ d["order"] = [c.name for c in self._provider_classes()]
1305
+ d["notify"] = [{"name": t["name"], "type": t["type"], "events": sorted(t["events"]), "url_env": t["url_env"],
1306
+ "url_set": bool(os.environ.get(t["url_env"]))} for t in N.settings(r)["targets"]]
1307
+ return d
1308
+
1309
+ def plugin_export(self, name, bom):
1310
+ """Run exporter plugin `name` on the librarian plan of a BOM; files go to <ws>/exports/<name>/<bom stem>/."""
1311
+ from . import plugins as PL
1312
+ bom_path = self._bom_path(bom)
1313
+ plan = self._librarian_dir(bom_path) / "plan.json"
1314
+ if not plan.exists():
1315
+ raise ValueError("run the librarian on this BOM first (no plan.json)")
1316
+ out = self.root / "exports" / self.safe_name(name) / self.safe_name(bom_path.stem)
1317
+ files = PL.run_exporter(name, plan, out, self.rules())
1318
+ return {"exporter": name, "out": self.rel(out),
1319
+ "files": [self.rel(f) if self.root in Path(f).resolve().parents else str(f) for f in files]}
1320
+
1321
+
1322
+ def _now(ts=None):
1323
+ t = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc) if ts else datetime.datetime.now(datetime.timezone.utc)
1324
+ return t.replace(microsecond=0).isoformat()
1325
+
1326
+
1327
+ def _nexar_token():
1328
+ tok = os.environ.get("NEXAR_TOKEN")
1329
+ if not tok:
1330
+ raise ValueError("set NEXAR_TOKEN before starting ecadlib (a Nexar portal token with the "
1331
+ "design.domain, user.access and openid scopes)")
1332
+ return tok
1333
+
1334
+
1335
+ def _svg_outline(g):
1336
+ """fp_line / fp_rect / fp_poly -> list of (x, y) points (KiCad frame) and whether it is closed."""
1337
+ if g[0] == "fp_poly":
1338
+ return [(float(c[1]), float(c[2])) for c in sexpr.find(sexpr.first(g, "pts") or [], "xy")], True
1339
+ (x1, y1), (x2, y2) = [(float(c[1]), float(c[2])) for c in (sexpr.first(g, "start"), sexpr.first(g, "end"))]
1340
+ return ([(x1, y1), (x2, y2)], False) if g[0] == "fp_line" else ([(x1, y1), (x2, y1), (x2, y2), (x1, y2)], True)
1341
+
1342
+
1343
+ def footprint_geometry(kicad_mod):
1344
+ """What the preview draws, from the .kicad_mod itself (KiCad frame, y down): courtyard and fab outlines, copper
1345
+ pads (rotation, rounded corners, polygon pads) and drills. Each item carries its points for the bounds."""
1346
+ t = sexpr.parse(kicad_mod)
1347
+ out = {"crtyd": [], "fab": [], "pads": [], "holes": []}
1348
+ for kind in ("fp_line", "fp_rect", "fp_poly"):
1349
+ for g in sexpr.find(t, kind):
1350
+ layer = (sexpr.first(g, "layer") or [None, None])[1]
1351
+ if layer in ("F.CrtYd", "F.Fab"):
1352
+ out["crtyd" if layer == "F.CrtYd" else "fab"].append(_svg_outline(g))
1353
+ for p in sexpr.find(t, "pad"):
1354
+ at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
1355
+ x, y, rot = float(at[1]), float(at[2]), float(at[3]) if len(at) > 3 else 0.0
1356
+ w, h = float(size[1]), float(size[2])
1357
+ layers = (sexpr.first(p, "layers") or ["layers"])[1:]
1358
+ a = math.radians(-rot) # KiCad: + is counter-clockwise on screen
1359
+ tf = lambda u, v: (x + u * math.cos(a) - v * math.sin(a), y + u * math.sin(a) + v * math.cos(a))
1360
+ d = sexpr.first(p, "drill")
1361
+ if d:
1362
+ vals = [float(v) for v in d[1:] if isinstance(v, str) and v != "oval"]
1363
+ dw, dh = vals[0], vals[1] if len(vals) > 1 else vals[0]
1364
+ out["holes"].append({"x": x, "y": y, "w": dw, "h": dh, "rot": rot,
1365
+ "pts": [tf(sx * dw / 2, sy * dh / 2) for sx in (-1, 1) for sy in (-1, 1)]})
1366
+ if p[2] == "np_thru_hole" or not any(l.endswith(".Cu") for l in layers):
1367
+ continue # holes only; paste windows aren't copper
1368
+ shape = p[3]
1369
+ pad = {"num": str(p[1]), "x": x, "y": y, "w": w, "h": h, "rot": rot}
1370
+ if shape == "custom":
1371
+ polys = [[tf(float(c[1]), float(c[2])) for c in sexpr.find(sexpr.first(g, "pts") or [], "xy")]
1372
+ for g in (sexpr.first(p, "primitives") or [])[1:] if g[0] == "gr_poly"]
1373
+ pad["polys"] = [q for q in polys if len(q) > 2]
1374
+ r = {"circle": 0.5, "oval": 0.5}.get(shape, 0.0)
1375
+ rr = sexpr.first(p, "roundrect_rratio")
1376
+ if shape == "roundrect" and rr:
1377
+ r = float(rr[1])
1378
+ pad["r"] = r * min(w, h)
1379
+ pad["pts"] = [tf(sx * w / 2, sy * h / 2) for sx in (-1, 1) for sy in (-1, 1)] + [q for poly in pad.get("polys", []) for q in poly]
1380
+ out["pads"].append(pad)
1381
+ return out
1382
+
1383
+
1384
+ _SVG_IDS = itertools.count(1)
1385
+
1386
+
1387
+ def footprint_svg(rep, scale=40, kicad_mod=None):
1388
+ """Top view: copper pads (pin 1 highlighted), drills, exposed pad, fab body and courtyard. KiCad Y points down,
1389
+ as SVG's does. The view box fits what is drawn (through-hole connectors put pin 1, not the centre, at 0,0).
1390
+ kicad_mod: draw from the footprint itself (rotation, polygon pads, holes); else from the report's numbers.
1391
+ The UI colours it like Altium's PCB editor: a black board under a 0.5 / 2.5 mm grid, pad numbers on the copper,
1392
+ and the footprint's origin as a cross. Through-hole pads (a drill at the pad) get the extra class "tht"."""
1393
+ f = lambda v: f"{float(v):.4f}".rstrip("0").rstrip(".")
1394
+ body, pts = [], []
1395
+ if kicad_mod:
1396
+ geo = footprint_geometry(kicad_mod)
1397
+ for cls in ("fab", "crtyd"):
1398
+ for line, closed in geo[cls]:
1399
+ pts += line
1400
+ d = " ".join(f"{f(x)},{f(y)}" for x, y in line)
1401
+ body.append(f'<{"polygon" if closed else "polyline"} class="{cls}" points="{d}"/>')
1402
+ pads = geo["pads"]
1403
+ else:
1404
+ bb = rep.get("courtyard_bbox")
1405
+ if not bb:
1406
+ cx, cy = float(rep["courtyard"][0]) / 2, float(rep["courtyard"][1]) / 2
1407
+ bb = (-cx, -cy, cx, cy)
1408
+ bb = [float(v) for v in bb]
1409
+ pts += [(bb[0], bb[1]), (bb[2], bb[3])]
1410
+ body.append(f'<rect class="crtyd" x="{f(bb[0])}" y="{f(bb[1])}" width="{f(bb[2] - bb[0])}" height="{f(bb[3] - bb[1])}"/>')
1411
+ if "EP" in rep:
1412
+ ew, eh = float(rep["EP"][0]), float(rep["EP"][1])
1413
+ body.append(f'<rect class="pad ep" x="{f(-ew / 2)}" y="{f(-eh / 2)}" width="{f(ew)}" height="{f(eh)}"/>')
1414
+ pads = []
1415
+ for p in rep["pads"]:
1416
+ x, y, w, h = (float(p[k]) for k in ("x", "y", "w", "h"))
1417
+ pads.append({"num": str(p["num"]), "x": x, "y": y, "w": w, "h": h, "rot": 0.0, "r": min(0.25, 0.25 * min(w, h)),
1418
+ "pts": [(x - w / 2, y - h / 2), (x + w / 2, y + h / 2)]})
1419
+ geo = {"holes": []}
1420
+ p1 = next((p["num"] for p in pads if p["num"] in ("1", "A1")), None)
1421
+ drilled = {(round(q["x"], 3), round(q["y"], 3)) for q in geo["holes"]}
1422
+ labels = []
1423
+ for p in pads:
1424
+ pts += p["pts"]
1425
+ cls = "pad p1" if p["num"] == p1 else "pad"
1426
+ if (round(p["x"], 3), round(p["y"], 3)) in drilled:
1427
+ cls += " tht"
1428
+ size = min(p["w"], p["h"]) * 0.55
1429
+ if size > 0:
1430
+ labels.append(f'<text class="padnum" x="{f(p["x"])}" y="{f(p["y"])}" font-size="{f(min(size, 0.6))}">'
1431
+ f'{html.escape(p["num"])}</text>')
1432
+ num = html.escape(p["num"]) # the UI inserts this SVG as HTML
1433
+ tip = f'<title>pad {num}: {p["w"]:.3f} x {p["h"]:.3f} mm @ ({p["x"]:.3f}, {p["y"]:.3f})</title>'
1434
+ if p.get("polys"):
1435
+ body.append(f'<g class="{cls}">' + "".join(
1436
+ '<polygon points="' + " ".join(f"{f(x)},{f(y)}" for x, y in q) + '"/>' for q in p["polys"]) + tip + "</g>")
1437
+ continue
1438
+ rot = f' transform="rotate({f(-p["rot"])} {f(p["x"])} {f(p["y"])})"' if p["rot"] else ""
1439
+ body.append(f'<rect class="{cls}" x="{f(p["x"] - p["w"] / 2)}" y="{f(p["y"] - p["h"] / 2)}" width="{f(p["w"])}" '
1440
+ f'height="{f(p["h"])}" rx="{f(p["r"])}"{rot}>{tip}</rect>')
1441
+ for q in geo["holes"]:
1442
+ pts += q["pts"]
1443
+ rot = f' transform="rotate({f(-q["rot"])} {f(q["x"])} {f(q["y"])})"' if q["rot"] else ""
1444
+ body.append(f'<rect class="hole" x="{f(q["x"] - q["w"] / 2)}" y="{f(q["y"] - q["h"] / 2)}" width="{f(q["w"])}" '
1445
+ f'height="{f(q["h"])}" rx="{f(min(q["w"], q["h"]) / 2)}"{rot}/>')
1446
+ m = 0.6
1447
+ x0, y0 = min(x for x, _ in pts) - m, min(y for _, y in pts) - m
1448
+ vw, vh = max(x for x, _ in pts) + m - x0, max(y for _, y in pts) + m - y0
1449
+ gid = f"fpgrid{next(_SVG_IDS)}"
1450
+ board = (f'<defs><pattern id="{gid}f" width="0.5" height="0.5" patternUnits="userSpaceOnUse">'
1451
+ f'<path class="grid" d="M0.5 0V0.5H0"/></pattern>'
1452
+ f'<pattern id="{gid}" width="2.5" height="2.5" patternUnits="userSpaceOnUse">'
1453
+ f'<rect width="2.5" height="2.5" fill="url(#{gid}f)"/><path class="grid major" d="M2.5 0V2.5H0"/></pattern></defs>'
1454
+ f'<rect class="board" x="{f(x0)}" y="{f(y0)}" width="{f(vw)}" height="{f(vh)}"/>'
1455
+ f'<rect class="board-grid" x="{f(x0)}" y="{f(y0)}" width="{f(vw)}" height="{f(vh)}" fill="url(#{gid})"/>')
1456
+ origin = '<path class="origin" d="M-0.4 0H0.4M0 -0.4V0.4"/>'
1457
+ return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{f(x0)} {f(y0)} {f(vw)} {f(vh)}" width="{vw * scale:.0f}" '
1458
+ f'height="{vh * scale:.0f}" class="fp">' + board + "".join(body) + "".join(labels) + origin + "</svg>")