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