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/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())