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/rebuild.py ADDED
@@ -0,0 +1,471 @@
1
+ """Reproducible rebuilds: library.lock.json records what every part was built from, `ecadlib rebuild --check` proves it.
2
+
3
+ ecadlib rebuild LIB_DIR --check [--json] [--keep DIR] rebuild into a temporary folder, offline, and compare
4
+ ecadlib rebuild LIB_DIR rebuild in place, offline, from the lock's settings
5
+
6
+ The librarian writes <out>/library.lock.json at the end of every run (no timestamps: a re-run that changes nothing
7
+ leaves it byte-identical):
8
+
9
+ {"schema_version": 1, "ecadlib": "0.3.0",
10
+ "bom": {"file": "../bom.csv", "sha256": ...},
11
+ "settings": {"sources": [...], "libs": [...], "cache": "../cache", "providers": null, "gen_step": true,
12
+ "rules_file": "", "rules_sha256": ...},
13
+ "parts": {"<name>": {"key": <the BOM-line fingerprint>,
14
+ "built_with": {"ecadlib": "0.3.0", "rules_sha256": ...},
15
+ "inputs": [{"method": "GET", "url": ..., "sha256": <sha256 of the response text>}, ...] or null,
16
+ "local": [{"file": <--lib file>, "sha256": ...}],
17
+ "datasheet": {"url", "pdf_sha256", "model", "provider", "schema"} (parts read from a datasheet),
18
+ "symbol": {"source", "file", "sha256", "norm_sha256"},
19
+ "footprint": {"source", "file", "sha256", "norm_sha256", "kicad_mod": {"file", "sha256"}}}}}
20
+
21
+ `inputs` are the HTTP responses the part's sources fetched (the KiCad library file, the EasyEDA component, ...), recorded
22
+ as they arrive (Recorder); null for a part built before ecadlib recorded them. `norm_sha256` hashes the file with what is
23
+ not content blanked: the .PcbLib library header's DATE/TIME (the only wall-clock value ecadlib writes; GUIDs and
24
+ unique ids are derived from the content).
25
+
26
+ `rebuild --check` copies the folder to a temporary one, deletes every model file the librarian made (files a person
27
+ chose with `ecadlib resolve` stay), and runs the librarian again from the lock's BOM, libraries, sources and rules with
28
+ `--offline` (the HTTP cache only; a datasheet part is rebuilt only from the cached model answer, a model is never
29
+ called). It then reports, per part and file, identical / identical after normalising / DRIFT, plus inputs that
30
+ changed (the BOM, the rules, a cached response, a --lib file) and parts built by another ecadlib version. Exit 0: no
31
+ drift; 1: drift; 2: cannot rebuild (no lock, BOM missing)."""
32
+ import argparse, contextlib, hashlib, json, os, re, shutil, sys, tempfile
33
+ from pathlib import Path
34
+
35
+ from . import __version__
36
+
37
+ LOCK = "library.lock.json"
38
+ SKIP_URL = re.compile(r"/repository/tree\?") # the KiCad name index crawl: shared, cached in its own file
39
+
40
+
41
+ def sha(data):
42
+ return hashlib.sha256(data if isinstance(data, bytes) else str(data).encode("utf-8")).hexdigest()
43
+
44
+
45
+ def file_sha(path):
46
+ p = Path(path)
47
+ return sha(p.read_bytes()) if p.is_file() else None
48
+
49
+
50
+ def norm_bytes(path):
51
+ """A file's bytes with wall-clock values blanked (.PcbLib: the library header's DATE / TIME)."""
52
+ p = Path(path)
53
+ data = p.read_bytes()
54
+ if p.suffix.lower() != ".pcblib":
55
+ return data
56
+ try:
57
+ import olefile
58
+ with olefile.OleFileIO(str(p)) as o:
59
+ parts = []
60
+ for s in sorted("/".join(e) for e in o.listdir(streams=True, storages=False)):
61
+ b = o.openstream(s).read()
62
+ if s == "Library/Data":
63
+ b = re.sub(rb"\|DATE=[^|]*", b"|DATE=", re.sub(rb"\|TIME=[^|]*", b"|TIME=", b, count=1), count=1)
64
+ parts.append(s.encode() + b"\0" + sha(b).encode())
65
+ return b"\n".join(parts)
66
+ except Exception: # not OLE: blank the raw bytes (same length kept)
67
+ return re.sub(rb"\|(DATE|TIME)=[0-9/:]*", rb"|\1=", data)
68
+
69
+
70
+ def norm_sha(path):
71
+ return sha(norm_bytes(path)) if Path(path).is_file() else None
72
+
73
+
74
+ def rules_hash(rules):
75
+ from . import rules as RU
76
+ return sha(json.dumps(RU.effective(rules), sort_keys=True, default=str))
77
+
78
+
79
+ # ------------------------------------------------------------------ recording what each part fetched
80
+ class Recorder:
81
+ """Wraps an HTTP client's get/post (CachedHTTP or a test fake) and notes, per part, every 2xx response it got:
82
+ method, URL (secrets redacted) and sha256 of the text. close() puts the client back as it was."""
83
+ def __init__(self, http):
84
+ self.http, self.cur, self.got, self._orig = http, None, {}, {}
85
+ for m in ("get", "post"):
86
+ f = getattr(http, m, None)
87
+ if not callable(f):
88
+ continue
89
+ try:
90
+ own = m in getattr(http, "__dict__", {})
91
+ setattr(http, m, self._wrap(m.upper(), f))
92
+ self._orig[m] = (f, own)
93
+ except (AttributeError, TypeError):
94
+ pass
95
+
96
+ def _wrap(self, method, f):
97
+ def call(url, *a, **kw):
98
+ r = f(url, *a, **kw)
99
+ try:
100
+ self._note(method, url, a, kw, r)
101
+ except Exception: # recording must never break a fetch
102
+ pass
103
+ return r
104
+ return call
105
+
106
+ def _note(self, method, url, a, kw, r):
107
+ if self.cur is None or SKIP_URL.search(str(url)):
108
+ return
109
+ status = getattr(r, "status", getattr(r, "status_code", None))
110
+ text = getattr(r, "text", None)
111
+ if not isinstance(status, int) or not 200 <= status < 300 or not isinstance(text, str):
112
+ return
113
+ from .httpcache import redact
114
+ e = {"method": method, "url": redact(str(url)), "sha256": sha(text)}
115
+ body = a[0] if a else kw.get("body")
116
+ if method == "POST" and body is not None:
117
+ e["body_sha256"] = sha(body if isinstance(body, (bytes, str)) else json.dumps(body, sort_keys=True))
118
+ lst = self.got.setdefault(self.cur, [])
119
+ if e not in lst:
120
+ lst.append(e)
121
+
122
+ @contextlib.contextmanager
123
+ def part(self, name):
124
+ prev, self.cur = self.cur, name
125
+ try:
126
+ yield self
127
+ finally:
128
+ self.cur = prev
129
+
130
+ def take(self, name):
131
+ return sorted(self.got.pop(name, []), key=lambda e: (e["url"], e["method"]))
132
+
133
+ def close(self):
134
+ for m, (f, own) in self._orig.items():
135
+ try:
136
+ if own:
137
+ setattr(self.http, m, f)
138
+ else:
139
+ delattr(self.http, m)
140
+ except AttributeError:
141
+ pass
142
+ self._orig = {}
143
+
144
+
145
+ def stamp_state(st, inputs, rules):
146
+ """What a freshly built part's librarian_state record keeps for the lock."""
147
+ st["inputs"] = inputs
148
+ st["built_with"] = {"ecadlib": __version__, "rules_sha256": rules_hash(rules)}
149
+ return st
150
+
151
+
152
+ # ------------------------------------------------------------------ the lock file
153
+ def _rel(path, base):
154
+ try:
155
+ return os.path.relpath(Path(path).resolve(), Path(base).resolve()).replace(os.sep, "/")
156
+ except ValueError: # another drive (Windows)
157
+ return str(Path(path).resolve())
158
+
159
+
160
+ def _abs(p, base):
161
+ p = Path(p)
162
+ return p if p.is_absolute() else (Path(base) / p).resolve()
163
+
164
+
165
+ def _local_inputs(source, libs):
166
+ """--lib files a local:<Lib>:<Name> source came from."""
167
+ m = re.match(r"local:([^:]+):(.+)$", str(source or ""))
168
+ if not m:
169
+ return []
170
+ lib, name = m.groups()
171
+ out = []
172
+ for root in libs:
173
+ for f in sorted(Path(root).rglob(f"{lib}.kicad_sym")) + sorted(Path(root).rglob(f"{lib}.pretty/{name}.kicad_mod")):
174
+ out.append(f)
175
+ return out[:2]
176
+
177
+
178
+ def _datasheet(out, url):
179
+ from . import datasheet as D
180
+ for f in sorted((Path(out) / "datasheet").glob("*.extraction.json")):
181
+ try:
182
+ s = json.loads(f.read_text(encoding="utf-8"))
183
+ except (OSError, ValueError):
184
+ continue
185
+ pdf = s.get("pdf") or {}
186
+ if url in (s.get("datasheet"), pdf.get("url")):
187
+ ai = s.get("ai") or {}
188
+ return {"url": url, "pdf_sha256": pdf.get("sha256"), "model": s.get("model") or ai.get("model"),
189
+ "provider": ai.get("provider") or "anthropic", "schema": D.schema_version(),
190
+ "extraction": _rel(f, out)}
191
+ return {"url": url, "pdf_sha256": None, "model": None, "provider": None, "schema": D.schema_version(), "extraction": None}
192
+
193
+
194
+ def _half(out, rec):
195
+ if not rec or not rec.get("file"):
196
+ return None
197
+ f = Path(out) / rec["file"]
198
+ h = {"source": rec.get("source"), "file": rec["file"], "sha256": file_sha(f), "norm_sha256": norm_sha(f)}
199
+ if rec.get("kicad_mod"):
200
+ h["kicad_mod"] = {"file": rec["kicad_mod"], "sha256": file_sha(Path(out) / rec["kicad_mod"])}
201
+ if rec.get("chosen"):
202
+ h["chosen"] = True
203
+ return h
204
+
205
+
206
+ def build_lock(out, items, state, bom, libs, sources, cache, providers, gen_step, rules):
207
+ out = Path(out)
208
+ parts = {}
209
+ for it in sorted(items, key=lambda i: i["name"]):
210
+ st = state.get(it["name"])
211
+ if st is None: # an Altium 365 item reused as it is: nothing was built
212
+ continue
213
+ p = {"key": st.get("key"), "built_with": st.get("built_with"),
214
+ "inputs": st.get("inputs"), "symbol": _half(out, st.get("symbol")), "footprint": _half(out, st.get("footprint"))}
215
+ local = []
216
+ for k in ("symbol", "footprint"):
217
+ for f in _local_inputs((st.get(k) or {}).get("source"), libs):
218
+ e = {"file": str(Path(f).resolve()), "sha256": file_sha(f)}
219
+ if e not in local:
220
+ local.append(e)
221
+ if local:
222
+ p["local"] = local
223
+ urls = sorted({str((st.get(k) or {}).get("source"))[len("datasheet:"):] for k in ("symbol", "footprint")
224
+ if str((st.get(k) or {}).get("source") or "").startswith("datasheet:")})
225
+ if urls:
226
+ p["datasheet"] = _datasheet(out, urls[0])
227
+ parts[it["name"]] = p
228
+ rf = (rules or {}).get("_file") or ""
229
+ return {"schema_version": 1, "ecadlib": __version__,
230
+ "bom": {"file": _rel(bom, out), "sha256": file_sha(bom)},
231
+ "settings": {"sources": list(sources), "libs": [_rel(l, out) for l in libs], "cache": _rel(cache, out),
232
+ "providers": providers if providers is None else list(providers) if not isinstance(providers, str)
233
+ else providers, "gen_step": bool(gen_step), "rules_file": _rel(rf, out) if rf else "",
234
+ "rules_sha256": rules_hash(rules)},
235
+ "parts": parts}
236
+
237
+
238
+ def write_lock(out, *a, **kw):
239
+ from .fsutil import write_text_atomic
240
+ lock = build_lock(out, *a, **kw)
241
+ text = json.dumps(lock, indent=1, sort_keys=False) + "\n"
242
+ f = Path(out) / LOCK
243
+ if not f.is_file() or f.read_text(encoding="utf-8") != text:
244
+ write_text_atomic(f, text)
245
+ return lock
246
+
247
+
248
+ def read_lock(lib_dir):
249
+ from . import migrate as MG
250
+ f = Path(lib_dir) / LOCK
251
+ data = json.loads(f.read_text(encoding="utf-8"))
252
+ MG.check_key("lock", data, str(f))
253
+ return data
254
+
255
+
256
+ # ------------------------------------------------------------------ rebuild
257
+ class _OfflineReader:
258
+ """Datasheet answers from the cache only: a rebuild never calls a model."""
259
+ def __init__(self, reader):
260
+ self._r = reader
261
+
262
+ def __getattr__(self, k):
263
+ return getattr(self._r, k)
264
+
265
+ def read_now(self, job, *a, **kw):
266
+ from .datasheet import DatasheetError
267
+ raise DatasheetError("not in the model-answer cache (rebuild is offline and never calls a model)")
268
+
269
+ def submit(self, job):
270
+ return self.read_now(job)
271
+
272
+ @property
273
+ def deferred(self):
274
+ return False
275
+
276
+
277
+ def _model_files(out, state):
278
+ """Files the librarian made (to delete before a clean rebuild); chosen files are kept."""
279
+ keep = {str((Path(out) / (st.get(k) or {}).get(f)).resolve()) for st in state.values() for k in ("symbol", "footprint")
280
+ for f in ("file", "kicad_mod") if (st.get(k) or {}).get("chosen") and (st.get(k) or {}).get(f)}
281
+ for f in sorted((Path(out) / "models").rglob("*")):
282
+ if f.is_file() and str(f.resolve()) not in keep:
283
+ yield f
284
+
285
+
286
+ def rebuild(lib_dir, out=None, log=print, clean=False):
287
+ """Run the librarian again from the lock, offline, into `out` (default: lib_dir itself). clean: first delete the
288
+ model files the librarian made (chosen ones stay), so every file is really rebuilt. -> (new lock, inputs drift)."""
289
+ from . import librarian as L, rules as RU
290
+ from .httpcache import CachedHTTP
291
+ lib_dir = Path(lib_dir).resolve()
292
+ lock = read_lock(lib_dir)
293
+ s = lock["settings"]
294
+ out = Path(out).resolve() if out else lib_dir
295
+ bom = _abs(lock["bom"]["file"], lib_dir)
296
+ if not bom.is_file():
297
+ raise FileNotFoundError(f"the lock's BOM {bom} is missing: cannot rebuild")
298
+ rules = RU.load(str(_abs(s["rules_file"], lib_dir))) if s.get("rules_file") else RU.load({})
299
+ cache = _abs(s["cache"], lib_dir)
300
+ libs = [str(_abs(l, lib_dir)) for l in s.get("libs") or []]
301
+ http = CachedHTTP(cache, offline=True)
302
+ drift = check_inputs(lock, lib_dir, rules, http)
303
+ reader = None
304
+ if any(p.get("datasheet") for p in lock["parts"].values()):
305
+ from . import datasheet as D
306
+ try:
307
+ reader = _OfflineReader(D.Reader(out, cache, "ecadlib-rebuild-offline", rules, http, log=lambda *a: None))
308
+ except Exception as e: # e.g. another provider whose key variable is not set here
309
+ log(f" rebuild: datasheet answers unavailable ({e})")
310
+ if clean:
311
+ state_f = out / "librarian_state.json"
312
+ state = json.loads(state_f.read_text(encoding="utf-8")) if state_f.is_file() else {}
313
+ for f in _model_files(out, state):
314
+ f.unlink()
315
+ L.run(str(bom), libs, str(out), sources=s["sources"], refresh=[], offline=True, cache=str(cache), http=http,
316
+ providers=s.get("providers"), gen_step=s.get("gen_step", True), rules=rules, reader=reader,
317
+ api_key="ecadlib-rebuild-offline" if reader else None, user="ecadlib rebuild", log=log)
318
+ return read_lock(out), drift
319
+
320
+
321
+ def check_inputs(lock, lib_dir, rules, http):
322
+ drift = []
323
+ bom = _abs(lock["bom"]["file"], lib_dir)
324
+ if file_sha(bom) != lock["bom"]["sha256"]:
325
+ drift.append({"part": "", "what": "bom", "detail": f"{lock['bom']['file']} changed since the lock was written"})
326
+ if rules_hash(rules) != lock["settings"]["rules_sha256"]:
327
+ drift.append({"part": "", "what": "rules", "detail": "the library rules changed since the lock was written"})
328
+ if lock.get("ecadlib") != __version__:
329
+ drift.append({"part": "", "what": "ecadlib", "detail": f"lock written by ecadlib {lock.get('ecadlib')}, "
330
+ f"rebuilding with {__version__}", "info": True})
331
+ for name, p in sorted(lock["parts"].items()):
332
+ bw = (p.get("built_with") or {}).get("ecadlib")
333
+ if bw and bw != __version__:
334
+ drift.append({"part": name, "what": "ecadlib", "detail": f"built by ecadlib {bw}", "info": True})
335
+ if p.get("inputs") is None and (p.get("symbol") or p.get("footprint")):
336
+ drift.append({"part": name, "what": "inputs", "detail": "inputs not recorded (built before ecadlib "
337
+ "recorded them)", "info": True})
338
+ for e in p.get("inputs") or []:
339
+ if e["method"] != "GET":
340
+ continue
341
+ r = http.get(e["url"])
342
+ if not r.ok:
343
+ drift.append({"part": name, "what": "input", "detail": f"{e['url']}: not in the HTTP cache"})
344
+ elif sha(r.text) != e["sha256"]:
345
+ drift.append({"part": name, "what": "input", "detail": f"{e['url']}: cached response changed"})
346
+ for e in p.get("local") or []:
347
+ if file_sha(e["file"]) != e["sha256"]:
348
+ drift.append({"part": name, "what": "input", "detail": f"{e['file']}: --lib file changed or missing"})
349
+ return drift
350
+
351
+
352
+ def compare(old, new):
353
+ """Per part and file: identical | normalised (same after blanking dates) | DRIFT. -> rows."""
354
+ rows = []
355
+ for name in sorted(set(old["parts"]) | set(new["parts"])):
356
+ a, b = old["parts"].get(name), new["parts"].get(name)
357
+ if a is None or b is None:
358
+ rows.append({"part": name, "file": "", "result": "DRIFT", "detail": "part only in the " + ("rebuild" if a is None else "lock")})
359
+ continue
360
+ for k in ("symbol", "footprint"):
361
+ x, y = a.get(k), b.get(k)
362
+ if not x and not y:
363
+ continue
364
+ if not x or not y:
365
+ rows.append({"part": name, "file": (x or y)["file"], "result": "DRIFT",
366
+ "detail": f"{k} {'missing in the rebuild' if x else 'only in the rebuild'}"})
367
+ continue
368
+ pairs = [(x["file"], y["file"], x["sha256"], y["sha256"], x.get("norm_sha256"), y.get("norm_sha256"), k)]
369
+ if x.get("kicad_mod") or y.get("kicad_mod"):
370
+ xm, ym = x.get("kicad_mod") or {}, y.get("kicad_mod") or {}
371
+ pairs.append((xm.get("file"), ym.get("file"), xm.get("sha256"), ym.get("sha256"), None, None, "kicad_mod"))
372
+ for fa, fb, ha, hb, na, nb, what in pairs:
373
+ if fa != fb:
374
+ rows.append({"part": name, "file": fa or fb, "result": "DRIFT", "detail": f"{what}: file {fa} -> {fb}"})
375
+ elif ha and ha == hb:
376
+ rows.append({"part": name, "file": fa, "result": "identical", "detail": ""})
377
+ elif na and na == nb:
378
+ rows.append({"part": name, "file": fa, "result": "normalised", "detail": "same content; only the library date differs"})
379
+ else:
380
+ rows.append({"part": name, "file": fa, "result": "DRIFT", "detail": f"{what}: content differs"})
381
+ if x.get("source") != y.get("source"):
382
+ rows.append({"part": name, "file": x["file"], "result": "DRIFT", "detail": f"{k} source {x.get('source')} -> {y.get('source')}"})
383
+ return rows
384
+
385
+
386
+ def check(lib_dir, keep=None, log=print):
387
+ """Rebuild into a temporary copy and compare with the lock. -> report dict (ok, rows, inputs)."""
388
+ lib_dir = Path(lib_dir).resolve()
389
+ old = read_lock(lib_dir)
390
+ tmp = Path(keep).resolve() if keep else Path(tempfile.mkdtemp(prefix="ecadlib-rebuild-"))
391
+ try:
392
+ dst = tmp / lib_dir.name
393
+ if dst.exists():
394
+ shutil.rmtree(dst)
395
+ shutil.copytree(lib_dir, dst, ignore=shutil.ignore_patterns(".ecadlib_backup", "*.tmp"))
396
+ # the copy points at the same BOM, libraries and cache: write the lock's relative paths as absolute
397
+ lk = json.loads((dst / LOCK).read_text(encoding="utf-8"))
398
+ lk["bom"]["file"] = str(_abs(lk["bom"]["file"], lib_dir))
399
+ s = lk["settings"]
400
+ s["libs"] = [str(_abs(l, lib_dir)) for l in s.get("libs") or []]
401
+ s["cache"] = str(_abs(s["cache"], lib_dir))
402
+ if s.get("rules_file"):
403
+ s["rules_file"] = str(_abs(s["rules_file"], lib_dir))
404
+ (dst / LOCK).write_text(json.dumps(lk), encoding="utf-8")
405
+ new, inputs = rebuild(dst, dst, log=log, clean=True)
406
+ rows = compare(old, new)
407
+ finally:
408
+ if not keep:
409
+ shutil.rmtree(tmp, ignore_errors=True)
410
+ bad = [r for r in rows if r["result"] == "DRIFT"] + [d for d in inputs if not d.get("info")]
411
+ return {"lock": str(lib_dir / LOCK), "ok": not bad, "rows": rows, "inputs": inputs,
412
+ "counts": {k: sum(r["result"] == k for r in rows) for k in ("identical", "normalised", "DRIFT")}}
413
+
414
+
415
+ def render(rep, styled=False, width=None, verbosity=0):
416
+ """The --check result as text: what drifted (in plain words), then the outcome."""
417
+ import io
418
+ from . import messages as M, term as TM
419
+ buf = io.StringIO()
420
+ t = TM.Term(buf, styled=styled, width=width, verbosity=verbosity)
421
+ lock = rep["lock"]
422
+ try:
423
+ rel = os.path.relpath(lock)
424
+ lock = rel if not rel.startswith("..") else lock
425
+ except ValueError:
426
+ pass
427
+ t.write(f"rebuild --check {lock}")
428
+ rows = [[t.status("info" if d.get("info") else "held", "note" if d.get("info") else "input changed"),
429
+ d["part"] or "(library)", M.clean(d["detail"])] for d in rep["inputs"]]
430
+ rows += [[t.status("held" if r["result"] == "DRIFT" else "skipped", r["result"].lower()), r["part"],
431
+ f"{r['file']} {M.clean(r['detail'])}".strip()] for r in rep["rows"] if r["result"] == "DRIFT" or (r["result"] != "identical" and t.verbose)]
432
+ t.table(rows)
433
+ c = rep["counts"]
434
+ t.summary(("OK: the library rebuilds from its lock" if rep["ok"] else "DRIFT found: the library does not rebuild the same")
435
+ + f" ({c['identical']} file(s) byte-identical, {c['normalised']} identical after normalising dates, {c['DRIFT']} drifted)",
436
+ next=[] if rep["ok"] else [("ecadlib rebuild LIB --check --keep /tmp/rb", "keep the rebuilt copy to compare")])
437
+ return buf.getvalue().strip("\n")
438
+
439
+
440
+ def main(argv=None):
441
+ ap = argparse.ArgumentParser(description="rebuild a librarian output folder from its library.lock.json, offline")
442
+ ap.add_argument("lib", help="the librarian output folder (holds library.lock.json)")
443
+ ap.add_argument("--check", action="store_true", help="rebuild into a temporary copy and report any drift; change nothing")
444
+ ap.add_argument("--keep", metavar="DIR", help="--check: rebuild into DIR and keep it (to look at a drift)")
445
+ ap.add_argument("--json", action="store_true")
446
+ a = ap.parse_args(argv)
447
+ lib = Path(a.lib)
448
+ if not (lib / LOCK).is_file():
449
+ print(f"ecadlib rebuild: {lib / LOCK} not found (run `ecadlib librarian` once to write it)", file=sys.stderr)
450
+ return 2
451
+ quiet = lambda *x: None
452
+ try:
453
+ if a.check:
454
+ from . import term as TM
455
+ with TM.working("rebuilding offline into a temporary copy"):
456
+ rep = check(lib, a.keep, log=quiet if a.json else quiet)
457
+ print(json.dumps(rep, indent=1) if a.json else render(rep, TM.wants_style(sys.stdout), TM.term_width(sys.stdout),
458
+ TM.VERBOSITY))
459
+ return 0 if rep["ok"] else 1
460
+ new, drift = rebuild(lib, log=quiet if a.json else print)
461
+ except FileNotFoundError as e:
462
+ print(f"ecadlib rebuild: {e}", file=sys.stderr)
463
+ return 2
464
+ rep = {"lock": str(lib / LOCK), "parts": len(new["parts"]), "inputs": drift}
465
+ print(json.dumps(rep, indent=1) if a.json else f"rebuilt {len(new['parts'])} part(s) from {lib / LOCK}"
466
+ + "".join(f"\n {'note ' if d.get('info') else 'INPUT'} {d['part'] or '(library)'}: {d['detail']}" for d in drift))
467
+ return 0
468
+
469
+
470
+ if __name__ == "__main__":
471
+ sys.exit(main())