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/migrate.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Versioned workspace files: one schema_version scheme, a registry of migrations, and `ecadlib migrate`.
|
|
2
|
+
|
|
3
|
+
ecadlib migrate [PATH] [--check] [--dry-run] [--json]
|
|
4
|
+
|
|
5
|
+
Everything ecadlib writes and later reads back has a schema version (an integer per *kind* of file, listed in KINDS):
|
|
6
|
+
|
|
7
|
+
* JSON state that other code iterates (plan.json is a list, librarian_state.json is keyed by part name, the queue is a
|
|
8
|
+
folder of item files, the agent folder holds several files) carries its version in the folder's stamp file,
|
|
9
|
+
`ecadlib_schema.json`: {"schema_version": 1, "ecadlib": "0.3.0", "kinds": {"plan": 1, "librarian_state": 2}}.
|
|
10
|
+
"ecadlib" is the version that last raised a kind in that folder (for the "made by ecadlib X.Y" message), so the stamp
|
|
11
|
+
does not change on every run.
|
|
12
|
+
* Files a person edits (ecadlib.toml, approvals.json) and new single-object files (library.lock.json) carry it in the
|
|
13
|
+
file itself: a top-level `schema_version` key. Absent = 1.
|
|
14
|
+
* A folder (or file) without a version is a workspace from before versioning: version 1 of every kind. Migrations
|
|
15
|
+
are written so that they are also correct on data that already has the newer shape (a stamp lost by copying one
|
|
16
|
+
file elsewhere must never damage it).
|
|
17
|
+
|
|
18
|
+
Reading an older version migrates it: in memory (`read`, `--dry-run`), or on disk by a command that writes the folder
|
|
19
|
+
anyway (`open_folder(write=True)`: the librarian, the agent, the queue, `ecadlib migrate`), which first copies every file
|
|
20
|
+
it changes to <folder>/.ecadlib_backup/<kind>-v<old>-<time>/. Reading a newer version raises SchemaTooNew with one
|
|
21
|
+
line: "<file> was made by ecadlib X.Y (<kind> schema N; this ecadlib 0.3.0 reads up to M): upgrade ecadlib".
|
|
22
|
+
|
|
23
|
+
To change a format: bump KINDS[kind].current and add MIGRATIONS[(kind, old)] = function(folder, files) -> {path: new
|
|
24
|
+
text}; a test that opens a fixture from before the change (tests/fixtures/upgrade/) proves it."""
|
|
25
|
+
import argparse, datetime, json, os, shutil, sys, time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from . import __version__
|
|
29
|
+
from .fsutil import write_text_atomic
|
|
30
|
+
|
|
31
|
+
STAMP = "ecadlib_schema.json"
|
|
32
|
+
STAMP_FORMAT = 1
|
|
33
|
+
BACKUP = ".ecadlib_backup"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SchemaTooNew(RuntimeError):
|
|
37
|
+
"""A file written by a newer ecadlib, in a format this one does not know."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Kind:
|
|
41
|
+
def __init__(self, name, current, files, where="stamp", note=""):
|
|
42
|
+
self.name, self.current, self.files, self.where, self.note = name, current, tuple(files), where, note
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# where="stamp": the version is in the folder's ecadlib_schema.json; "key": a top-level schema_version in the file
|
|
46
|
+
KINDS = {k.name: k for k in (
|
|
47
|
+
Kind("plan", 1, ["plan.json"], note="librarian / altium365 plan (a JSON list of parts)"),
|
|
48
|
+
Kind("librarian_state", 2, ["librarian_state.json"],
|
|
49
|
+
note="what the librarian found per part; v2: footprint name + geometry hash recorded, `inputs` for the lock"),
|
|
50
|
+
Kind("approvals_audit", 1, ["approvals_audit.jsonl"], note="append-only sign-off log"),
|
|
51
|
+
Kind("agent", 1, ["status.json", "agent_state.json", "watch_state.json", "watch_pending.json", "library_bom.json"],
|
|
52
|
+
note="library agent state (<workspace>/agent/)"),
|
|
53
|
+
Kind("queue", 1, ["items", "batches"], note="review queue (<workspace>/agent/queue/: one JSON file per item)"),
|
|
54
|
+
Kind("http_cache", 1, ["kicad_library_index.json"], note="HTTP cache folder (--cache)"),
|
|
55
|
+
Kind("rules", 1, ["ecadlib.toml", "ecadlib_rules.json"], where="key", note="library rules"),
|
|
56
|
+
Kind("approvals_policy", 1, ["approvals.json"], where="key", note="sign-off policy next to a plan"),
|
|
57
|
+
Kind("lock", 1, ["library.lock.json"], where="key", note="exact inputs per part (ecadlib rebuild)"),
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------ migrations
|
|
62
|
+
def _state_1_to_2(folder, data):
|
|
63
|
+
"""librarian_state v1 -> v2: footprint records get the name and geometry hash fp_share records since round 7.
|
|
64
|
+
The hash is the one fp_share would compute from the record's .kicad_mod anyway, so nothing is decided
|
|
65
|
+
differently; a footprint that got another part's file by name only (unchecked share) is left without a hash so the
|
|
66
|
+
librarian still re-checks it."""
|
|
67
|
+
from . import fp_share as FS
|
|
68
|
+
for rec in (data or {}).values():
|
|
69
|
+
fp = (rec or {}).get("footprint") if isinstance(rec, dict) else None
|
|
70
|
+
if not isinstance(fp, dict) or fp.get("hash") or FS.unchecked_share(fp):
|
|
71
|
+
continue
|
|
72
|
+
k = Path(folder) / (fp.get("kicad_mod") or "")
|
|
73
|
+
if fp.get("kicad_mod") and k.is_file():
|
|
74
|
+
fp["hash"] = FS.digest(k.read_text(encoding="utf-8"))
|
|
75
|
+
if not fp.get("name") and fp.get("file"):
|
|
76
|
+
fp["name"] = Path(fp["file"]).stem
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# (kind, from_version) -> function(folder, parsed data of the kind's main file) -> migrated data
|
|
81
|
+
MIGRATIONS = {("librarian_state", 1): _state_1_to_2}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------ stamps
|
|
85
|
+
def _ver(v):
|
|
86
|
+
try:
|
|
87
|
+
return int(v)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read(f):
|
|
93
|
+
"""Windows refuses a read while another process is replacing the file (os.replace): wait a moment and retry."""
|
|
94
|
+
from .fsutil import DELAYS
|
|
95
|
+
for delay in DELAYS + (None,):
|
|
96
|
+
try:
|
|
97
|
+
return f.read_text(encoding="utf-8")
|
|
98
|
+
except PermissionError:
|
|
99
|
+
if os.name != "nt" or delay is None:
|
|
100
|
+
raise
|
|
101
|
+
time.sleep(delay)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_stamp(folder):
|
|
105
|
+
f = Path(folder) / STAMP
|
|
106
|
+
try:
|
|
107
|
+
d = json.loads(_read(f))
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
return None
|
|
110
|
+
except (OSError, ValueError) as e:
|
|
111
|
+
raise SchemaTooNew(f"{f}: unreadable ({e}); restore it from {BACKUP}/ or delete it to treat the folder as "
|
|
112
|
+
f"ecadlib 0.3.0 data") from None
|
|
113
|
+
if not isinstance(d, dict) or _ver(d.get("schema_version")) > STAMP_FORMAT:
|
|
114
|
+
raise SchemaTooNew(f"{f} was made by ecadlib {d.get('ecadlib', '?') if isinstance(d, dict) else '?'} "
|
|
115
|
+
f"(stamp format {d.get('schema_version') if isinstance(d, dict) else '?'}; this ecadlib "
|
|
116
|
+
f"{__version__} reads up to {STAMP_FORMAT}): upgrade ecadlib (pip install -U ecadlib)")
|
|
117
|
+
return d
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def write_stamp(folder, kinds):
|
|
121
|
+
"""Record the current version of `kinds` in folder's stamp (other kinds already there are kept). Writes only when
|
|
122
|
+
something changes, so a re-run leaves the file alone."""
|
|
123
|
+
folder = Path(folder)
|
|
124
|
+
old = read_stamp(folder) or {}
|
|
125
|
+
have = dict(old.get("kinds") or {})
|
|
126
|
+
new = dict(have, **{k: KINDS[k].current for k in kinds})
|
|
127
|
+
if new == have and old.get("schema_version") == STAMP_FORMAT:
|
|
128
|
+
return False
|
|
129
|
+
d = {"schema_version": STAMP_FORMAT, "ecadlib": __version__ if new != have else old.get("ecadlib", __version__),
|
|
130
|
+
"kinds": dict(sorted(new.items()))}
|
|
131
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
write_text_atomic(folder / STAMP, json.dumps(d, indent=1) + "\n")
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def too_new(kind, version, made_by, where):
|
|
137
|
+
k = KINDS[kind]
|
|
138
|
+
return SchemaTooNew(f"{where} was made by ecadlib {made_by or '?'} ({kind} schema {version}; this ecadlib "
|
|
139
|
+
f"{__version__} reads up to {k.current}): upgrade ecadlib (pip install -U ecadlib)")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def folder_version(folder, kind):
|
|
143
|
+
"""(version, made_by) of `kind` in folder: from the stamp, 1 when there is none (data from before versioning)."""
|
|
144
|
+
st = read_stamp(folder) or {}
|
|
145
|
+
return _ver((st.get("kinds") or {}).get(kind, 1)), st.get("ecadlib")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def check_version(kind, version, made_by="", where=""):
|
|
149
|
+
"""Raise SchemaTooNew for a version this ecadlib does not know; returns the version as an int."""
|
|
150
|
+
v = _ver(version)
|
|
151
|
+
if v > KINDS[kind].current:
|
|
152
|
+
raise too_new(kind, v, made_by, where or kind)
|
|
153
|
+
return v
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def check_key(kind, data, where=""):
|
|
157
|
+
"""For where="key" kinds: check data's top-level schema_version (absent = 1) and return data without it."""
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
return data
|
|
160
|
+
check_version(kind, data.get("schema_version", 1), data.get("ecadlib", ""), where)
|
|
161
|
+
return {k: v for k, v in data.items() if k not in ("schema_version",)}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------ reading and upgrading a folder
|
|
165
|
+
def _main_file(folder, kind):
|
|
166
|
+
for n in KINDS[kind].files:
|
|
167
|
+
if (Path(folder) / n).exists():
|
|
168
|
+
return Path(folder) / n
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def kinds_in(folder):
|
|
173
|
+
"""The stamp kinds whose files are in this folder."""
|
|
174
|
+
folder = Path(folder)
|
|
175
|
+
out = [k for k, d in KINDS.items() if d.where == "stamp" and any((folder / n).exists() for n in d.files)]
|
|
176
|
+
if "queue" in out and folder.name != "queue":
|
|
177
|
+
out.remove("queue") # items/ and batches/ only mean a queue in agent/queue
|
|
178
|
+
if "agent" in out and folder.name != "agent":
|
|
179
|
+
out.remove("agent")
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def pending(folder, kinds=None):
|
|
184
|
+
"""[(kind, from_version, to_version)] for kinds in folder that are older than this ecadlib; raises for newer."""
|
|
185
|
+
folder = Path(folder)
|
|
186
|
+
st = read_stamp(folder) or {}
|
|
187
|
+
out = []
|
|
188
|
+
for k in kinds if kinds is not None else kinds_in(folder):
|
|
189
|
+
v = check_version(k, (st.get("kinds") or {}).get(k, 1), st.get("ecadlib"), str(_main_file(folder, k) or folder))
|
|
190
|
+
if v < KINDS[k].current:
|
|
191
|
+
out.append((k, v, KINDS[k].current))
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def migrate_data(kind, version, data, folder="."):
|
|
196
|
+
"""Data of `kind` at `version` -> data at the current version (in memory)."""
|
|
197
|
+
v = check_version(kind, version)
|
|
198
|
+
while v < KINDS[kind].current:
|
|
199
|
+
fn = MIGRATIONS.get((kind, v))
|
|
200
|
+
if fn:
|
|
201
|
+
data = fn(Path(folder), data)
|
|
202
|
+
v += 1
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def read(path, kind=None):
|
|
207
|
+
"""A versioned JSON file, migrated in memory to the current version (nothing is written)."""
|
|
208
|
+
path = Path(path)
|
|
209
|
+
kind = kind or next((k for k, d in KINDS.items() if path.name in d.files), None)
|
|
210
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
211
|
+
if kind is None:
|
|
212
|
+
return data
|
|
213
|
+
if KINDS[kind].where == "key":
|
|
214
|
+
return check_key(kind, data, str(path))
|
|
215
|
+
v, made = folder_version(path.parent, kind)
|
|
216
|
+
check_version(kind, v, made, str(path))
|
|
217
|
+
return migrate_data(kind, v, data, path.parent)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _backup(folder, kind, version, files, t):
|
|
221
|
+
d = Path(folder) / BACKUP / f"{kind}-v{version}-{t}"
|
|
222
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
for f in files:
|
|
224
|
+
shutil.copy2(f, d / Path(f).name)
|
|
225
|
+
return d
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def open_folder(folder, kinds=None, write=True, log=None):
|
|
229
|
+
"""Check a folder ecadlib is about to use. Newer than this ecadlib -> SchemaTooNew. Older -> migrated on disk
|
|
230
|
+
(write=True, after a backup) or left alone (write=False: callers then read through read()). write=True also stamps
|
|
231
|
+
the current versions of `kinds` (default: the kinds found in the folder). Returns [(kind, from, to)] migrated."""
|
|
232
|
+
folder = Path(folder)
|
|
233
|
+
kinds = list(kinds) if kinds is not None else kinds_in(folder)
|
|
234
|
+
todo = pending(folder, kinds) if folder.is_dir() else []
|
|
235
|
+
if not write:
|
|
236
|
+
return todo
|
|
237
|
+
t = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
|
|
238
|
+
for kind, old, new in todo:
|
|
239
|
+
f = _main_file(folder, kind)
|
|
240
|
+
steps = [MIGRATIONS[(kind, v)] for v in range(old, new) if (kind, v) in MIGRATIONS]
|
|
241
|
+
if f and f.is_file() and steps:
|
|
242
|
+
b = _backup(folder, kind, old, [f], t)
|
|
243
|
+
data = migrate_data(kind, old, json.loads(f.read_text(encoding="utf-8")), folder)
|
|
244
|
+
write_text_atomic(f, json.dumps(data, indent=2))
|
|
245
|
+
if log:
|
|
246
|
+
log(f" migrated {f} from {kind} v{old} to v{new} (backup in {b})")
|
|
247
|
+
if folder.is_dir() and kinds:
|
|
248
|
+
write_stamp(folder, kinds)
|
|
249
|
+
return todo
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------ whole workspace
|
|
253
|
+
SKIP_DIRS = {BACKUP, "datasheets", "__pycache__", ".git", "node_modules"}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def scan(root, max_depth=6):
|
|
257
|
+
"""Every versioned thing under root -> [{"path", "kind", "version", "current", "made_by", "state"}].
|
|
258
|
+
state: ok | old (migration due) | new (made by a newer ecadlib) | error."""
|
|
259
|
+
root = Path(root)
|
|
260
|
+
rows = []
|
|
261
|
+
|
|
262
|
+
def add(path, kind, v, made):
|
|
263
|
+
cur = KINDS[kind].current
|
|
264
|
+
rows.append({"path": str(path), "kind": kind, "version": v, "current": cur, "made_by": made or "",
|
|
265
|
+
"state": "new" if v > cur else "old" if v < cur else "ok"})
|
|
266
|
+
|
|
267
|
+
for d, subdirs, files in os.walk(root):
|
|
268
|
+
rel_depth = len(Path(d).relative_to(root).parts)
|
|
269
|
+
subdirs[:] = sorted(s for s in subdirs if s not in SKIP_DIRS and rel_depth < max_depth
|
|
270
|
+
and not (Path(d).name == "queue" and s in ("items", "batches")))
|
|
271
|
+
d = Path(d)
|
|
272
|
+
if (d / "kicad_library_index.json").exists() or d.name == "http_cache":
|
|
273
|
+
subdirs[:] = [] # a cache: thousands of entries, nothing else to find inside
|
|
274
|
+
try:
|
|
275
|
+
st = read_stamp(d) or {}
|
|
276
|
+
except SchemaTooNew as e:
|
|
277
|
+
rows.append({"path": str(d / STAMP), "kind": "stamp", "version": None, "current": STAMP_FORMAT,
|
|
278
|
+
"made_by": "", "state": "new", "error": str(e)})
|
|
279
|
+
continue
|
|
280
|
+
for k in kinds_in(d) + [k for k in (st.get("kinds") or {}) if k in KINDS and k not in kinds_in(d)]:
|
|
281
|
+
add(_main_file(d, k) or d, k, _ver((st.get("kinds") or {}).get(k, 1)), st.get("ecadlib"))
|
|
282
|
+
for k in (st.get("kinds") or {}):
|
|
283
|
+
if k not in KINDS: # a kind only a newer ecadlib has: not ours to read, not an error
|
|
284
|
+
rows.append({"path": str(d), "kind": k, "version": st["kinds"][k], "current": None,
|
|
285
|
+
"made_by": st.get("ecadlib", ""), "state": "unknown"})
|
|
286
|
+
for k, kd in KINDS.items():
|
|
287
|
+
if kd.where != "key":
|
|
288
|
+
continue
|
|
289
|
+
for n in kd.files:
|
|
290
|
+
f = d / n
|
|
291
|
+
if not f.is_file():
|
|
292
|
+
continue
|
|
293
|
+
try:
|
|
294
|
+
v, made = _key_version(f)
|
|
295
|
+
except ValueError as e:
|
|
296
|
+
rows.append({"path": str(f), "kind": k, "version": None, "current": kd.current, "made_by": "",
|
|
297
|
+
"state": "error", "error": str(e)})
|
|
298
|
+
continue
|
|
299
|
+
add(f, k, v, made)
|
|
300
|
+
return rows
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _key_version(f):
|
|
304
|
+
f = Path(f)
|
|
305
|
+
text = f.read_text(encoding="utf-8")
|
|
306
|
+
if f.suffix == ".toml":
|
|
307
|
+
from .rules import toml_module
|
|
308
|
+
tomllib = toml_module()
|
|
309
|
+
if tomllib is None: # Python 3.10 without tomli: a plain look at the top-level key
|
|
310
|
+
import re
|
|
311
|
+
m = re.search(r"(?m)^\s*schema_version\s*=\s*(\d+)", text.split("[", 1)[0])
|
|
312
|
+
return (int(m.group(1)) if m else 1), ""
|
|
313
|
+
try:
|
|
314
|
+
d = tomllib.loads(text)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
raise ValueError(f"not valid TOML ({e})") from None
|
|
317
|
+
else:
|
|
318
|
+
try:
|
|
319
|
+
d = json.loads(text or "{}")
|
|
320
|
+
except ValueError as e:
|
|
321
|
+
raise ValueError(f"not valid JSON ({e})") from None
|
|
322
|
+
d = d if isinstance(d, dict) else {}
|
|
323
|
+
return _ver(d.get("schema_version", 1)), str(d.get("ecadlib") or "")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_CHECKED = {}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def guard_tree(root):
|
|
330
|
+
"""Read-only check of a whole workspace (once per process per folder): raise SchemaTooNew when anything in it was
|
|
331
|
+
written by a newer ecadlib. Used where a workspace is opened (the web UI / MCP Workspace)."""
|
|
332
|
+
root = Path(root).resolve()
|
|
333
|
+
if root in _CHECKED:
|
|
334
|
+
return
|
|
335
|
+
for r in scan(root, max_depth=4):
|
|
336
|
+
if r["state"] == "new":
|
|
337
|
+
if r["kind"] == "stamp":
|
|
338
|
+
raise SchemaTooNew(r["error"])
|
|
339
|
+
raise too_new(r["kind"], r["version"], r["made_by"], r["path"])
|
|
340
|
+
_CHECKED[root] = True
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def migrate_tree(root, dry_run=False, log=print):
|
|
344
|
+
"""Bring every versioned folder under root to the current versions (backups first). -> scan rows with 'action'."""
|
|
345
|
+
rows = scan(root)
|
|
346
|
+
bad = [r for r in rows if r["state"] in ("new", "error")]
|
|
347
|
+
if bad:
|
|
348
|
+
return rows
|
|
349
|
+
for r in rows:
|
|
350
|
+
r["action"] = ""
|
|
351
|
+
if r["state"] != "old":
|
|
352
|
+
continue
|
|
353
|
+
folder = Path(r["path"]).parent if Path(r["path"]).is_file() else Path(r["path"])
|
|
354
|
+
steps = [v for v in range(r["version"], r["current"]) if (r["kind"], v) in MIGRATIONS]
|
|
355
|
+
r["action"] = (f"migrate v{r['version']} -> v{r['current']}" if steps else f"stamp v{r['current']} (no change to the data)")
|
|
356
|
+
if not dry_run:
|
|
357
|
+
open_folder(folder, [r["kind"]], write=True, log=log)
|
|
358
|
+
return rows
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def main(argv=None):
|
|
362
|
+
ap = argparse.ArgumentParser(description="check or upgrade the files of an ecadlib workspace or library folder to "
|
|
363
|
+
"this ecadlib's formats (backups in .ecadlib_backup/)")
|
|
364
|
+
ap.add_argument("path", nargs="?", default=".", help="workspace or library folder (default: current folder)")
|
|
365
|
+
ap.add_argument("--check", action="store_true", help="change nothing; exit 1 if anything needs migrating or is newer")
|
|
366
|
+
ap.add_argument("--dry-run", action="store_true", help="show what would be migrated, change nothing")
|
|
367
|
+
ap.add_argument("--json", action="store_true", help="print the rows as JSON")
|
|
368
|
+
a = ap.parse_args(argv)
|
|
369
|
+
root = Path(a.path)
|
|
370
|
+
if not root.exists():
|
|
371
|
+
print(f"ecadlib migrate: {root}: no such folder", file=sys.stderr)
|
|
372
|
+
return 2
|
|
373
|
+
rows = migrate_tree(root, dry_run=a.check or a.dry_run, log=(lambda s: None) if a.json else print)
|
|
374
|
+
if a.json:
|
|
375
|
+
print(json.dumps(rows, indent=1))
|
|
376
|
+
else:
|
|
377
|
+
from . import messages as M, term as TM
|
|
378
|
+
t = TM.Term()
|
|
379
|
+
dry = a.check or a.dry_run
|
|
380
|
+
table = []
|
|
381
|
+
for r in rows:
|
|
382
|
+
what = {"ok": "up to date", "old": r.get("action") or f"v{r['version']} -> v{r['current']}",
|
|
383
|
+
"new": r.get("error") or f"made by ecadlib {r['made_by'] or '?'}: upgrade ecadlib",
|
|
384
|
+
"unknown": f"kind from a newer ecadlib {r['made_by']} (left alone)", "error": r.get("error", "")}[r["state"]]
|
|
385
|
+
st, word = {"ok": ("ready", "ok"), "old": ("signoff", "to migrate" if dry else "migrated"),
|
|
386
|
+
"new": ("failed", "newer"), "unknown": ("skipped", "left alone"), "error": ("failed", "unreadable")}[r["state"]]
|
|
387
|
+
if r["state"] == "ok" and not t.verbose and len(rows) > 12:
|
|
388
|
+
continue # long trees: only what needs attention (-v lists every file)
|
|
389
|
+
table.append([t.status(st, word), os.path.relpath(r["path"], root), M.clean(what)])
|
|
390
|
+
t.table(table)
|
|
391
|
+
n_old = sum(r["state"] == "old" for r in rows)
|
|
392
|
+
n_bad = sum(r["state"] in ("new", "error") for r in rows)
|
|
393
|
+
verb = "would be migrated" if dry else "migrated"
|
|
394
|
+
nxt = []
|
|
395
|
+
if dry and n_old:
|
|
396
|
+
nxt.append((f"ecadlib migrate {a.path}", "upgrade them (backups go to .ecadlib_backup/ first)"))
|
|
397
|
+
if n_bad:
|
|
398
|
+
nxt.append(("pip install -U ecadlib", "a file was written by a newer ecadlib"))
|
|
399
|
+
t.summary(f"{M.plural(len(rows), 'versioned file')}: {n_old} {verb}, {n_bad} newer or unreadable"
|
|
400
|
+
+ ("; nothing written" if dry else ""), next=nxt)
|
|
401
|
+
if any(r["state"] in ("new", "error") for r in rows):
|
|
402
|
+
return 1
|
|
403
|
+
return 1 if a.check and any(r["state"] == "old" for r in rows) else 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
if __name__ == "__main__":
|
|
407
|
+
sys.exit(main())
|