rocket-engine 0.0.2b1__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 (241) hide show
  1. rocket_engine/__init__.py +8 -0
  2. rocket_engine/__main__.py +12 -0
  3. rocket_engine/adapters/README.md +54 -0
  4. rocket_engine/adapters/__init__.py +7 -0
  5. rocket_engine/adapters/api.py +39 -0
  6. rocket_engine/adapters/base.py +46 -0
  7. rocket_engine/adapters/cace/__init__.py +163 -0
  8. rocket_engine/adapters/circulax/README.md +10 -0
  9. rocket_engine/adapters/circulax/__init__.py +112 -0
  10. rocket_engine/adapters/circulax/_driver.py +190 -0
  11. rocket_engine/adapters/circulax/adapter.py +538 -0
  12. rocket_engine/adapters/circulax/results.py +291 -0
  13. rocket_engine/adapters/cli/README.md +34 -0
  14. rocket_engine/adapters/cli/__init__.py +305 -0
  15. rocket_engine/adapters/connectivity/__init__.py +196 -0
  16. rocket_engine/adapters/connectivity/results.py +214 -0
  17. rocket_engine/adapters/cosim/README.md +9 -0
  18. rocket_engine/adapters/cosim/__init__.py +1390 -0
  19. rocket_engine/adapters/cosim/results.py +214 -0
  20. rocket_engine/adapters/devsim/__init__.py +171 -0
  21. rocket_engine/adapters/digital_flow/README.md +9 -0
  22. rocket_engine/adapters/digital_flow/__init__.py +1121 -0
  23. rocket_engine/adapters/digital_flow/results.py +227 -0
  24. rocket_engine/adapters/digital_flow/state.py +462 -0
  25. rocket_engine/adapters/drc/README.klayout.md +7 -0
  26. rocket_engine/adapters/drc/README.md +7 -0
  27. rocket_engine/adapters/drc/__init__.py +1 -0
  28. rocket_engine/adapters/drc/klayout.py +952 -0
  29. rocket_engine/adapters/drc/magic.py +287 -0
  30. rocket_engine/adapters/drc/results.py +184 -0
  31. rocket_engine/adapters/drc/results_kl.py +188 -0
  32. rocket_engine/adapters/elmer/__init__.py +163 -0
  33. rocket_engine/adapters/envelope.py +120 -0
  34. rocket_engine/adapters/equivalence/__init__.py +156 -0
  35. rocket_engine/adapters/errors.py +181 -0
  36. rocket_engine/adapters/extract_magic/README.md +15 -0
  37. rocket_engine/adapters/extract_magic/__init__.py +425 -0
  38. rocket_engine/adapters/extract_magic/results.py +209 -0
  39. rocket_engine/adapters/formal_sby/README.md +7 -0
  40. rocket_engine/adapters/formal_sby/__init__.py +773 -0
  41. rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
  42. rocket_engine/adapters/formal_sby/results.py +185 -0
  43. rocket_engine/adapters/gdsfactory/__init__.py +194 -0
  44. rocket_engine/adapters/gdsfactory/_driver.py +459 -0
  45. rocket_engine/adapters/gdsfactory/adapter.py +609 -0
  46. rocket_engine/adapters/gdsfactory/results.py +181 -0
  47. rocket_engine/adapters/hdl_sim/README.md +7 -0
  48. rocket_engine/adapters/hdl_sim/__init__.py +369 -0
  49. rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
  50. rocket_engine/adapters/hdl_sim/results.py +178 -0
  51. rocket_engine/adapters/hdl_sim/verilator.py +167 -0
  52. rocket_engine/adapters/http/README.md +15 -0
  53. rocket_engine/adapters/http/__init__.py +458 -0
  54. rocket_engine/adapters/http/events.py +63 -0
  55. rocket_engine/adapters/ipc/README.md +9 -0
  56. rocket_engine/adapters/ipc/__init__.py +111 -0
  57. rocket_engine/adapters/lvs_netgen/README.md +20 -0
  58. rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
  59. rocket_engine/adapters/lvs_netgen/results.py +168 -0
  60. rocket_engine/adapters/mcp/README.md +19 -0
  61. rocket_engine/adapters/mcp/__init__.py +85 -0
  62. rocket_engine/adapters/mcp/api-schema.json +6107 -0
  63. rocket_engine/adapters/mcp/pull.py +246 -0
  64. rocket_engine/adapters/mcp/server.py +794 -0
  65. rocket_engine/adapters/meep/__init__.py +167 -0
  66. rocket_engine/adapters/meow/README.md +7 -0
  67. rocket_engine/adapters/meow/__init__.py +190 -0
  68. rocket_engine/adapters/meow/_driver.py +227 -0
  69. rocket_engine/adapters/meow/_envelope.py +108 -0
  70. rocket_engine/adapters/meow/adapter.py +491 -0
  71. rocket_engine/adapters/meow/results.py +218 -0
  72. rocket_engine/adapters/mutation/__init__.py +192 -0
  73. rocket_engine/adapters/ngspice/README.md +28 -0
  74. rocket_engine/adapters/ngspice/__init__.py +627 -0
  75. rocket_engine/adapters/ngspice/results.py +215 -0
  76. rocket_engine/adapters/postlayout/README.md +28 -0
  77. rocket_engine/adapters/postlayout/__init__.py +17 -0
  78. rocket_engine/adapters/postlayout/adapter.py +1062 -0
  79. rocket_engine/adapters/postlayout/results.py +330 -0
  80. rocket_engine/adapters/postlayout/translate.py +71 -0
  81. rocket_engine/adapters/profile.py +220 -0
  82. rocket_engine/adapters/sax/README.md +23 -0
  83. rocket_engine/adapters/sax/__init__.py +516 -0
  84. rocket_engine/adapters/sax/_driver.py +148 -0
  85. rocket_engine/adapters/sax/results.py +291 -0
  86. rocket_engine/adapters/stdio_console.py +1151 -0
  87. rocket_engine/adapters/syntax/README.md +5 -0
  88. rocket_engine/adapters/syntax/__init__.py +366 -0
  89. rocket_engine/adapters/syntax/results.py +181 -0
  90. rocket_engine/adapters/synth_yosys/README.md +7 -0
  91. rocket_engine/adapters/synth_yosys/__init__.py +655 -0
  92. rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
  93. rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
  94. rocket_engine/adapters/synth_yosys/results.py +173 -0
  95. rocket_engine/adapters/timing_sta/README.md +9 -0
  96. rocket_engine/adapters/timing_sta/__init__.py +726 -0
  97. rocket_engine/adapters/view_sync/__init__.py +185 -0
  98. rocket_engine/adapters/view_sync/results.py +189 -0
  99. rocket_engine/adapters/vlsir/__init__.py +142 -0
  100. rocket_engine/adapters/xschem/__init__.py +440 -0
  101. rocket_engine/adapters/xschem/deck.py +266 -0
  102. rocket_engine/clients/__init__.py +5 -0
  103. rocket_engine/clients/runtime.py +255 -0
  104. rocket_engine/composition.py +2279 -0
  105. rocket_engine/execution/README.md +30 -0
  106. rocket_engine/execution/__init__.py +150 -0
  107. rocket_engine/execution/apig/__init__.py +1 -0
  108. rocket_engine/execution/apig/pool.py +463 -0
  109. rocket_engine/execution/apig/submit.py +124 -0
  110. rocket_engine/execution/block_runs_scope.py +479 -0
  111. rocket_engine/execution/coalesce.py +414 -0
  112. rocket_engine/execution/console/__init__.py +1 -0
  113. rocket_engine/execution/console/apply_session.py +318 -0
  114. rocket_engine/execution/console/lifecycle.py +265 -0
  115. rocket_engine/execution/dispatch.py +205 -0
  116. rocket_engine/execution/errors.py +31 -0
  117. rocket_engine/execution/ports.py +797 -0
  118. rocket_engine/execution/pyexec/__init__.py +1 -0
  119. rocket_engine/execution/pyexec/apply_context.py +547 -0
  120. rocket_engine/execution/pyexec/approvals.py +173 -0
  121. rocket_engine/execution/pyexec/runner.py +404 -0
  122. rocket_engine/execution/pyexec/support.py +464 -0
  123. rocket_engine/execution/python_exec.py +897 -0
  124. rocket_engine/execution/queue.py +85 -0
  125. rocket_engine/execution/replay_safe.py +601 -0
  126. rocket_engine/execution/routing/__init__.py +1 -0
  127. rocket_engine/execution/routing/calls.py +364 -0
  128. rocket_engine/execution/routing/namespace.py +785 -0
  129. rocket_engine/execution/routing/ports.py +102 -0
  130. rocket_engine/execution/routing/tools.py +346 -0
  131. rocket_engine/execution/runner.py +311 -0
  132. rocket_engine/execution/runtime.py +1149 -0
  133. rocket_engine/execution/service.py +279 -0
  134. rocket_engine/execution/worker.py +464 -0
  135. rocket_engine/interfaces/README.md +26 -0
  136. rocket_engine/interfaces/__init__.py +58 -0
  137. rocket_engine/interfaces/apig_assets.py +407 -0
  138. rocket_engine/interfaces/celllink.py +461 -0
  139. rocket_engine/interfaces/documents.py +92 -0
  140. rocket_engine/interfaces/envelopes.py +131 -0
  141. rocket_engine/interfaces/errors.py +16 -0
  142. rocket_engine/interfaces/layout.py +410 -0
  143. rocket_engine/interfaces/layout_schema.yaml +278 -0
  144. rocket_engine/interfaces/paths.py +223 -0
  145. rocket_engine/interfaces/pdks.py +550 -0
  146. rocket_engine/interfaces/ports.py +72 -0
  147. rocket_engine/interfaces/references.py +115 -0
  148. rocket_engine/interfaces/refs.py +85 -0
  149. rocket_engine/interfaces/runparams.py +304 -0
  150. rocket_engine/interfaces/setup.py +552 -0
  151. rocket_engine/interfaces/setup_catalogue.yaml +243 -0
  152. rocket_engine/interfaces/setup_probes.py +66 -0
  153. rocket_engine/interfaces/vocabulary.py +58 -0
  154. rocket_engine/interfaces/yaml_dump.py +31 -0
  155. rocket_engine/library/README.md +22 -0
  156. rocket_engine/library/__init__.py +13 -0
  157. rocket_engine/library/apig_catalog.py +371 -0
  158. rocket_engine/library/catalog.py +185 -0
  159. rocket_engine/library/errors.py +16 -0
  160. rocket_engine/mutate/__init__.py +776 -0
  161. rocket_engine/mutate/operators.py +700 -0
  162. rocket_engine/ops/__init__.py +1 -0
  163. rocket_engine/ops/deferred.py +279 -0
  164. rocket_engine/ops/editors.py +404 -0
  165. rocket_engine/ops/evidence.py +194 -0
  166. rocket_engine/ops/gates.py +262 -0
  167. rocket_engine/ops/ports.py +76 -0
  168. rocket_engine/ops/queries.py +417 -0
  169. rocket_engine/ops/runs.py +553 -0
  170. rocket_engine/ops/submit.py +614 -0
  171. rocket_engine/project/README.md +27 -0
  172. rocket_engine/project/__init__.py +6 -0
  173. rocket_engine/project/canvas/__init__.py +55 -0
  174. rocket_engine/project/canvas/compile.py +109 -0
  175. rocket_engine/project/canvas/integrate.py +48 -0
  176. rocket_engine/project/canvas/schema.py +241 -0
  177. rocket_engine/project/canvas/templates.py +46 -0
  178. rocket_engine/project/canvas/validate.py +603 -0
  179. rocket_engine/project/edit/__init__.py +5 -0
  180. rocket_engine/project/edit/cas.py +87 -0
  181. rocket_engine/project/edit/transactions.py +282 -0
  182. rocket_engine/project/errors.py +16 -0
  183. rocket_engine/project/frontend13.py +685 -0
  184. rocket_engine/project/geometry/__init__.py +23 -0
  185. rocket_engine/project/geometry/ops.py +451 -0
  186. rocket_engine/project/geometry/snap.py +103 -0
  187. rocket_engine/project/ip/__init__.py +1 -0
  188. rocket_engine/project/ip/blocks.py +195 -0
  189. rocket_engine/project/ip/entry.schema.json +78 -0
  190. rocket_engine/project/ip/lifecycle.py +402 -0
  191. rocket_engine/project/ip/verbs.py +123 -0
  192. rocket_engine/project/layers/__init__.py +6 -0
  193. rocket_engine/project/layers/bindings.py +115 -0
  194. rocket_engine/project/layers/catalog.py +228 -0
  195. rocket_engine/project/layers/presets.py +43 -0
  196. rocket_engine/project/ledger/__init__.py +1 -0
  197. rocket_engine/project/ledger/docs.py +167 -0
  198. rocket_engine/project/ledger/entities.py +176 -0
  199. rocket_engine/project/ledger/events.py +29 -0
  200. rocket_engine/project/ledger/runs.py +257 -0
  201. rocket_engine/project/ledger/shared.py +174 -0
  202. rocket_engine/project/ledger/snapshots.py +385 -0
  203. rocket_engine/project/ledger/wires.py +552 -0
  204. rocket_engine/project/process/__init__.py +14 -0
  205. rocket_engine/project/process/model.py +193 -0
  206. rocket_engine/project/process/recipes.py +121 -0
  207. rocket_engine/project/schematic/__init__.py +1 -0
  208. rocket_engine/project/schematic/hierarchy.py +312 -0
  209. rocket_engine/project/schematic/instances.py +393 -0
  210. rocket_engine/project/schematic/sheets.py +198 -0
  211. rocket_engine/project/service.py +2941 -0
  212. rocket_engine/project/symbols/__init__.py +1 -0
  213. rocket_engine/project/symbols/cells.py +324 -0
  214. rocket_engine/project/symbols/linkage.py +356 -0
  215. rocket_engine/project/symbols/symbols.py +352 -0
  216. rocket_engine/project/templates/__init__.py +303 -0
  217. rocket_engine/project/templates/errors.py +25 -0
  218. rocket_engine/project/testbench/__init__.py +49 -0
  219. rocket_engine/project/testbench/blocks.py +619 -0
  220. rocket_engine/project/testbench/templates.py +134 -0
  221. rocket_engine/stores/README.md +12 -0
  222. rocket_engine/stores/__init__.py +6 -0
  223. rocket_engine/stores/ecs.py +1508 -0
  224. rocket_engine/stores/layers.py +1023 -0
  225. rocket_engine/stores/settings.py +1188 -0
  226. rocket_engine/stores/wireplace.py +1324 -0
  227. rocket_engine/workflows/README.md +18 -0
  228. rocket_engine/workflows/__init__.py +53 -0
  229. rocket_engine/workflows/capability_plan.py +102 -0
  230. rocket_engine/workflows/completion.py +228 -0
  231. rocket_engine/workflows/connectgraph.py +734 -0
  232. rocket_engine/workflows/connectgraph_emit.py +336 -0
  233. rocket_engine/workflows/gates.py +144 -0
  234. rocket_engine/workflows/photonic_wire.py +76 -0
  235. rocket_engine/workflows/stages.py +324 -0
  236. rocket_engine/workflows/viewsync.py +399 -0
  237. rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
  238. rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
  239. rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
  240. rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
  241. rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1188 @@
1
+ """Engine-owned design-truth settings store (SETTINGS-STORE).
2
+
3
+ Five versioned schemas hold every design decision the engine makes —
4
+ KiCad's `.kicad_pro` split, adopted as a rule, not code:
5
+
6
+ - ``chip.manifest``: project name, PDK ref+version, canvas list.
7
+ - ``design.rules``: nested ERC defaults, DRC deck ref, LVS config.
8
+ - ``net.classes``: default class plus per-net color/visibility split.
9
+ - ``text.vars``: scalar map with ClearUnknownKeys delete semantics.
10
+ - ``lib.tables``: resolve-only library refs (never written through).
11
+
12
+ Mechanics: ``meta.version`` per file, chained
13
+ ``register_migration(old, new, fn)`` links, additive-params-first
14
+ migration, merge-onto-original preserving unknown keys,
15
+ future-format blocks autosave, byte-compare skip, atomic write, one
16
+ writer per key with unset-fill merge, and a resolved-config
17
+ snapshot per session/run for clients (never per-event round-trips).
18
+ """
19
+
20
+ import copy
21
+ import io
22
+ import os
23
+ import tempfile
24
+ from pathlib import Path
25
+
26
+ from ruamel.yaml import YAML
27
+
28
+ # Design-truth kinds this engine version reads and writes.
29
+ CURRENT_VERSIONS = {
30
+ "chip.manifest": 1,
31
+ "design.rules": 1,
32
+ "net.classes": 1,
33
+ "text.vars": 1,
34
+ "lib.tables": 1,
35
+ }
36
+
37
+ # Project-relative directory holding the five settings files.
38
+ SETTINGS_DIRNAME = "settings"
39
+
40
+ _FILENAMES = {kind: f"{kind}.yaml" for kind in CURRENT_VERSIONS}
41
+
42
+ # Data-map paths replaced wholesale by merge-onto-original: the new
43
+ # map is complete truth, so deletes inside propagate. Everywhere
44
+ # else the merge preserves original-only keys recursively, so keys
45
+ # this engine does not know survive a round-trip.
46
+ _REPLACE_PATHS = {
47
+ "chip.manifest": frozenset(),
48
+ "design.rules": frozenset({("erc", "checks")}),
49
+ "net.classes": frozenset({("classes",), ("nets",)}),
50
+ "text.vars": frozenset({("vars",)}),
51
+ "lib.tables": frozenset({("tables",)}),
52
+ }
53
+
54
+ # (kind, old_version) -> (new_version, fn). Links chain one step at
55
+ # a time; migrate() walks from the file version to the head.
56
+ _MIGRATIONS = {}
57
+
58
+ _SCALARS = (str, int, float, bool)
59
+
60
+
61
+ class SettingsError(Exception):
62
+ """Typed settings-store failure; `code` names the verdict.
63
+
64
+ Codes: unknown_kind, invalid_settings, future_format,
65
+ missing_migration, invalid_migration, migration_conflict,
66
+ writer_conflict, unknown_library, missing_file.
67
+ """
68
+
69
+ def __init__(self, code, message):
70
+ super().__init__(message)
71
+ self.code = code
72
+
73
+
74
+ def _dump_yaml(data):
75
+ """Canonical settings text (the byte-compare baseline)."""
76
+ buf = io.StringIO()
77
+ YAML(typ="safe").dump(copy.deepcopy(data), buf)
78
+ return buf.getvalue()
79
+
80
+
81
+ def _load_yaml(text, path):
82
+ """Parse settings text, failing loudly on non-mapping tops."""
83
+ data = YAML(typ="safe").load(text)
84
+ if not isinstance(data, dict):
85
+ raise SettingsError(
86
+ "invalid_settings", f"Top level must be a mapping: {path}."
87
+ )
88
+ return data
89
+
90
+
91
+ def _deep_merge(base, overlay):
92
+ """Overlay wins; mappings merge recursively, lists replace."""
93
+ if not isinstance(base, dict) or not isinstance(overlay, dict):
94
+ return copy.deepcopy(overlay)
95
+ merged = copy.deepcopy(base)
96
+ for key, value in overlay.items():
97
+ if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
98
+ merged[key] = _deep_merge(merged[key], value)
99
+ else:
100
+ merged[key] = copy.deepcopy(value)
101
+ return merged
102
+
103
+
104
+ def _merge_onto_original(original, new, replace, path=()):
105
+ """Merge `new` onto `original`, preserving unknown keys.
106
+
107
+ Original-only keys survive recursively; conflicts resolve to
108
+ `new`; data-map paths in `replace` (and every list) take `new`
109
+ wholesale so deletes propagate there.
110
+ """
111
+ if path in replace or not isinstance(original, dict) or not isinstance(new, dict):
112
+ return copy.deepcopy(new)
113
+ result = copy.deepcopy(new)
114
+ for key, value in original.items():
115
+ if key not in result:
116
+ result[key] = copy.deepcopy(value)
117
+ elif isinstance(value, dict) and isinstance(result[key], dict):
118
+ result[key] = _merge_onto_original(
119
+ value, result[key], replace, path + (key,)
120
+ )
121
+ return result
122
+
123
+
124
+ def _require_kind(kind):
125
+ """Fail loudly on an unknown settings kind."""
126
+ if kind not in CURRENT_VERSIONS:
127
+ raise SettingsError("unknown_kind", f"Unknown settings kind: {kind}.")
128
+ return kind
129
+
130
+
131
+ def _head_version(kind):
132
+ """Newest version this engine knows: current or migration head."""
133
+ _require_kind(kind)
134
+ head = CURRENT_VERSIONS[kind]
135
+ for (reg_kind, _old), (new, _fn) in _MIGRATIONS.items():
136
+ if reg_kind == kind and new > head:
137
+ head = new
138
+ return head
139
+
140
+
141
+ def _version_of(kind, data):
142
+ """meta.version of a mapping, failing loudly when malformed."""
143
+ meta = data.get("meta")
144
+ version = meta.get("version") if isinstance(meta, dict) else None
145
+ if isinstance(version, bool) or not isinstance(version, int):
146
+ raise SettingsError(
147
+ "invalid_settings",
148
+ f"{kind} carries no integer meta.version.",
149
+ )
150
+ return version
151
+
152
+
153
+ def _is_nonempty_str(value):
154
+ """True for non-blank strings only."""
155
+ return isinstance(value, str) and value.strip() != ""
156
+
157
+
158
+ def _check_manifest(data):
159
+ """Validate chip.manifest shape; unknown keys pass through."""
160
+ if not _is_nonempty_str(data.get("name")):
161
+ raise SettingsError(
162
+ "invalid_settings", "chip.manifest needs a non-empty name."
163
+ )
164
+ pdk = data.get("pdk")
165
+ if not isinstance(pdk, dict):
166
+ raise SettingsError(
167
+ "invalid_settings", "chip.manifest needs a pdk mapping."
168
+ )
169
+ for key in ("ref", "version"):
170
+ if not _is_nonempty_str(pdk.get(key)):
171
+ raise SettingsError(
172
+ "invalid_settings",
173
+ f"chip.manifest pdk needs a non-empty {key}.",
174
+ )
175
+ canvases = data.get("canvases")
176
+ if not isinstance(canvases, list) or not canvases:
177
+ raise SettingsError(
178
+ "invalid_settings", "chip.manifest needs a non-empty canvas list."
179
+ )
180
+ for canvas in canvases:
181
+ if not isinstance(canvas, dict) or not _is_nonempty_str(canvas.get("name")):
182
+ raise SettingsError(
183
+ "invalid_settings", "Every chip.manifest canvas needs a name."
184
+ )
185
+
186
+
187
+ def _check_rules(data):
188
+ """Validate design.rules shape; unknown keys pass through."""
189
+ erc = data.get("erc")
190
+ if not isinstance(erc, dict) or not isinstance(erc.get("default"), bool):
191
+ raise SettingsError(
192
+ "invalid_settings", "design.rules erc needs a bool default."
193
+ )
194
+ checks = erc.get("checks")
195
+ if not isinstance(checks, dict):
196
+ raise SettingsError(
197
+ "invalid_settings", "design.rules erc needs a checks mapping."
198
+ )
199
+ for name, enabled in checks.items():
200
+ if not isinstance(enabled, bool):
201
+ raise SettingsError(
202
+ "invalid_settings",
203
+ f"design.rules erc check {name!r} must be on/off.",
204
+ )
205
+ drc = data.get("drc")
206
+ deck = drc.get("deck") if isinstance(drc, dict) else None
207
+ if not isinstance(deck, dict) or not _is_nonempty_str(deck.get("ref")):
208
+ raise SettingsError(
209
+ "invalid_settings", "design.rules drc needs a deck ref."
210
+ )
211
+ if not isinstance(data.get("lvs"), dict):
212
+ raise SettingsError(
213
+ "invalid_settings", "design.rules needs an lvs mapping."
214
+ )
215
+
216
+
217
+ def _check_classes(data):
218
+ """Validate net.classes shape; unknown keys pass through."""
219
+ classes = data.get("classes")
220
+ if not isinstance(classes, dict) or not classes:
221
+ raise SettingsError(
222
+ "invalid_settings", "net.classes needs a non-empty classes map."
223
+ )
224
+ for name, entry in classes.items():
225
+ if not isinstance(entry, dict):
226
+ raise SettingsError(
227
+ "invalid_settings", f"net.classes class {name!r} must map."
228
+ )
229
+ if not _is_nonempty_str(entry.get("color")):
230
+ raise SettingsError(
231
+ "invalid_settings",
232
+ f"net.classes class {name!r} needs a color.",
233
+ )
234
+ if not isinstance(entry.get("visible"), bool):
235
+ raise SettingsError(
236
+ "invalid_settings",
237
+ f"net.classes class {name!r} needs visible on/off.",
238
+ )
239
+ if data.get("default") not in classes:
240
+ raise SettingsError(
241
+ "invalid_settings", "net.classes default names no known class."
242
+ )
243
+ nets = data.get("nets")
244
+ if not isinstance(nets, dict):
245
+ raise SettingsError(
246
+ "invalid_settings", "net.classes needs a nets mapping."
247
+ )
248
+ for net, class_name in nets.items():
249
+ if class_name not in classes:
250
+ raise SettingsError(
251
+ "invalid_settings",
252
+ f"net.classes net {net!r} names no known class.",
253
+ )
254
+
255
+
256
+ def _check_text_vars(data):
257
+ """Validate text.vars scalars; unknown top-level keys pass through."""
258
+ variables = data.get("vars")
259
+ if not isinstance(variables, dict):
260
+ raise SettingsError(
261
+ "invalid_settings", "text.vars needs a vars mapping."
262
+ )
263
+ for name, value in variables.items():
264
+ if not isinstance(value, _SCALARS):
265
+ raise SettingsError(
266
+ "invalid_settings",
267
+ f"text.vars var {name!r} must be a scalar.",
268
+ )
269
+
270
+
271
+ def _check_lib_tables(data):
272
+ """Validate lib.tables refs; unknown keys pass through."""
273
+ tables = data.get("tables")
274
+ if not isinstance(tables, dict):
275
+ raise SettingsError(
276
+ "invalid_settings", "lib.tables needs a tables mapping."
277
+ )
278
+ for name, entry in tables.items():
279
+ if not isinstance(entry, dict) or not _is_nonempty_str(entry.get("ref")):
280
+ raise SettingsError(
281
+ "invalid_settings", f"lib.tables entry {name!r} needs a ref."
282
+ )
283
+
284
+
285
+ _CHECKS = {
286
+ "chip.manifest": _check_manifest,
287
+ "design.rules": _check_rules,
288
+ "net.classes": _check_classes,
289
+ "text.vars": _check_text_vars,
290
+ "lib.tables": _check_lib_tables,
291
+ }
292
+
293
+
294
+ def default_settings(kind):
295
+ """Build fresh defaults for one settings kind.
296
+
297
+ Args:
298
+ kind: Settings kind (one of CURRENT_VERSIONS).
299
+
300
+ Returns:
301
+ Valid settings mapping stamped at the current version.
302
+
303
+ Raises:
304
+ SettingsError: unknown_kind for an uncataloged kind.
305
+ """
306
+ _require_kind(kind)
307
+ meta = {"version": CURRENT_VERSIONS[kind]}
308
+ if kind == "chip.manifest":
309
+ return {
310
+ "meta": meta,
311
+ "name": "untitled",
312
+ "pdk": {"ref": "unassigned", "version": "unassigned"},
313
+ "canvases": [{"name": "top"}],
314
+ }
315
+ if kind == "design.rules":
316
+ return {
317
+ "meta": meta,
318
+ "erc": {
319
+ "default": True,
320
+ "checks": {
321
+ "short": True,
322
+ "open": True,
323
+ "unconnected_pin": True,
324
+ "power_pin": True,
325
+ },
326
+ },
327
+ "drc": {"deck": {"ref": "unassigned", "version": "unassigned"}},
328
+ "lvs": {"tool": "unassigned", "options": {}},
329
+ }
330
+ if kind == "net.classes":
331
+ return {
332
+ "meta": meta,
333
+ "default": "signal",
334
+ "classes": {
335
+ "signal": {"color": "#3fa9f5", "visible": True},
336
+ "power": {"color": "#e74c3c", "visible": True},
337
+ },
338
+ "nets": {"VDD": "power", "VSS": "power"},
339
+ }
340
+ if kind == "text.vars":
341
+ return {"meta": meta, "vars": {}}
342
+ return {"meta": meta, "tables": {}}
343
+
344
+
345
+ def fresh_project_settings(name, pdk_ref, pdk_version, canvases=()):
346
+ """Build a fresh project's full design truth (all five kinds).
347
+
348
+ Args:
349
+ name: Project/chip name for the manifest.
350
+ pdk_ref: PDK reference (e.g. sky130A).
351
+ pdk_version: PDK version string.
352
+ canvases: Canvas names or {name, ...} mappings; a lone
353
+ "top" canvas when omitted.
354
+
355
+ Returns:
356
+ Mapping of settings kind to valid current-version data.
357
+
358
+ Raises:
359
+ SettingsError: invalid_settings for a blank name, PDK
360
+ ref/version, or a canvas without a name.
361
+ """
362
+ if not _is_nonempty_str(name):
363
+ raise SettingsError(
364
+ "invalid_settings", "Fresh projects need a non-empty name."
365
+ )
366
+ if not _is_nonempty_str(pdk_ref) or not _is_nonempty_str(pdk_version):
367
+ raise SettingsError(
368
+ "invalid_settings", "Fresh projects need a PDK ref+version."
369
+ )
370
+ normalized = []
371
+ for canvas in canvases:
372
+ entry = {"name": canvas} if isinstance(canvas, str) else dict(canvas)
373
+ if not _is_nonempty_str(entry.get("name")):
374
+ raise SettingsError(
375
+ "invalid_settings", "Every fresh canvas needs a name."
376
+ )
377
+ normalized.append(entry)
378
+ fresh = {kind: default_settings(kind) for kind in CURRENT_VERSIONS}
379
+ manifest = fresh["chip.manifest"]
380
+ manifest["name"] = name
381
+ manifest["pdk"] = {"ref": pdk_ref, "version": pdk_version}
382
+ if normalized:
383
+ manifest["canvases"] = normalized
384
+ return fresh
385
+
386
+
387
+ def validate_settings(kind, data):
388
+ """Validate one settings mapping against its kind schema.
389
+
390
+ Unknown keys pass through untouched (merge-onto-original owns
391
+ them); only known-key shapes and the head version are judged.
392
+
393
+ Args:
394
+ kind: Settings kind (one of CURRENT_VERSIONS).
395
+ data: Settings mapping to validate.
396
+
397
+ Returns:
398
+ The same mapping, unchanged, when valid.
399
+
400
+ Raises:
401
+ SettingsError: unknown_kind for an uncataloged kind,
402
+ invalid_settings for a non-mapping, a version that is
403
+ not the known head, or a misshapen known key.
404
+ """
405
+ _require_kind(kind)
406
+ if not isinstance(data, dict):
407
+ raise SettingsError(
408
+ "invalid_settings", f"{kind} settings must be a mapping."
409
+ )
410
+ version = _version_of(kind, data)
411
+ head = _head_version(kind)
412
+ if version != head:
413
+ raise SettingsError(
414
+ "invalid_settings",
415
+ f"{kind} is at version {version}, want {head}.",
416
+ )
417
+ _CHECKS[kind](data)
418
+ return data
419
+
420
+
421
+ def settings_version(kind, data):
422
+ """Read one settings mapping's meta.version.
423
+
424
+ Args:
425
+ kind: Settings kind (one of CURRENT_VERSIONS).
426
+ data: Settings mapping to inspect.
427
+
428
+ Returns:
429
+ Integer meta.version carried by the mapping.
430
+
431
+ Raises:
432
+ SettingsError: unknown_kind for an uncataloged kind,
433
+ invalid_settings for a non-mapping or a version that
434
+ is not an integer.
435
+ """
436
+ _require_kind(kind)
437
+ if not isinstance(data, dict):
438
+ raise SettingsError(
439
+ "invalid_settings", f"{kind} settings must be a mapping."
440
+ )
441
+ return _version_of(kind, data)
442
+
443
+
444
+ def register_migration(kind, old, new, fn):
445
+ """Register one chained migration link for a settings kind.
446
+
447
+ Links chain one step at a time (new must be old + 1); migrate()
448
+ walks them from the file version to the head. Registration is
449
+ additive: engine upgrades add links, never rewrite old ones.
450
+
451
+ Args:
452
+ kind: Settings kind (one of CURRENT_VERSIONS).
453
+ old: Source meta.version this link migrates from.
454
+ new: Target meta.version (must be old + 1).
455
+ fn: Callable taking the old mapping and returning the new
456
+ mapping; additive-params-first means its output lands
457
+ on top of defaults, so new params exist even where
458
+ the migrator says nothing.
459
+
460
+ Raises:
461
+ SettingsError: unknown_kind for an uncataloged kind,
462
+ invalid_migration for a skipped version step,
463
+ migration_conflict when a link already leaves old.
464
+ """
465
+ _require_kind(kind)
466
+ if new != old + 1:
467
+ raise SettingsError(
468
+ "invalid_migration",
469
+ f"{kind} migration must step one version, got {old}->{new}.",
470
+ )
471
+ if (kind, old) in _MIGRATIONS:
472
+ raise SettingsError(
473
+ "migration_conflict",
474
+ f"{kind} already migrates from version {old}.",
475
+ )
476
+ _MIGRATIONS[(kind, old)] = (new, fn)
477
+
478
+
479
+ def clear_migrations(kind=None):
480
+ """Drop registered migration links (a test seam, not a feature).
481
+
482
+ Args:
483
+ kind: Drop only this kind's links; all links when omitted.
484
+
485
+ Returns:
486
+ Count of links dropped.
487
+
488
+ Raises:
489
+ SettingsError: unknown_kind for an uncataloged kind.
490
+ """
491
+ if kind is not None:
492
+ _require_kind(kind)
493
+ doomed = [key for key in _MIGRATIONS if kind is None or key[0] == kind]
494
+ for key in doomed:
495
+ del _MIGRATIONS[key]
496
+ return len(doomed)
497
+
498
+
499
+ def migrate(kind, data):
500
+ """Migrate one settings mapping to the known head version.
501
+
502
+ Walks registered links from the mapping's meta.version upward.
503
+ Additive-params-first: each step lands the migrator's output on
504
+ top of defaults, so brand-new params exist even where the
505
+ migrator says nothing. Migration is pure (no I/O); callers
506
+ persist the result through the normal save path.
507
+
508
+ Args:
509
+ kind: Settings kind (one of CURRENT_VERSIONS).
510
+ data: Settings mapping at some older meta.version.
511
+
512
+ Returns:
513
+ Migrated mapping stamped and validated at the head.
514
+
515
+ Raises:
516
+ SettingsError: unknown_kind for an uncataloged kind,
517
+ invalid_settings for a non-mapping or a version that
518
+ is not an integer, future_format for a mapping newer
519
+ than the head, missing_migration for a version step
520
+ with no registered link, invalid_migration when a
521
+ migrator returns a non-mapping or raises.
522
+ """
523
+ _require_kind(kind)
524
+ if not isinstance(data, dict):
525
+ raise SettingsError(
526
+ "invalid_settings", f"{kind} settings must be a mapping."
527
+ )
528
+ version = _version_of(kind, data)
529
+ head = _head_version(kind)
530
+ if version > head:
531
+ raise SettingsError(
532
+ "future_format",
533
+ f"{kind} is at version {version}; this engine knows {head}.",
534
+ )
535
+ current = copy.deepcopy(data)
536
+ while version < head:
537
+ link = _MIGRATIONS.get((kind, version))
538
+ if link is None:
539
+ raise SettingsError(
540
+ "missing_migration",
541
+ f"{kind} has no migration from version {version}.",
542
+ )
543
+ new, fn = link
544
+ try:
545
+ stepped = fn(copy.deepcopy(current))
546
+ except SettingsError:
547
+ raise
548
+ except Exception as exc:
549
+ raise SettingsError(
550
+ "invalid_migration",
551
+ f"{kind} migration {version}->{new} failed: {exc}.",
552
+ ) from exc
553
+ if not isinstance(stepped, dict):
554
+ raise SettingsError(
555
+ "invalid_migration",
556
+ f"{kind} migration {version}->{new} returned no mapping.",
557
+ )
558
+ current = _deep_merge(default_settings(kind), stepped)
559
+ current["meta"] = {"version": new}
560
+ version = new
561
+ current["meta"] = {"version": head}
562
+ return validate_settings(kind, current)
563
+
564
+
565
+ def apply_update(kind, current, update):
566
+ """Apply a patch onto loaded settings, honoring delete semantics.
567
+
568
+ Structured kinds deep-merge the patch (overlay wins, lists
569
+ replace). text.vars uses ClearUnknownKeys semantics: a patch
570
+ carrying `vars` replaces the whole map, so keys the patch omits
571
+ are deleted. The patch never moves meta.version.
572
+
573
+ Args:
574
+ kind: Settings kind (one of CURRENT_VERSIONS).
575
+ current: Loaded settings mapping (usually at the head).
576
+ update: Patch mapping to apply.
577
+
578
+ Returns:
579
+ New settings mapping at the current version.
580
+
581
+ Raises:
582
+ SettingsError: unknown_kind for an uncataloged kind,
583
+ invalid_settings for a non-mapping patch or a patch
584
+ that breaks the kind schema.
585
+ """
586
+ _require_kind(kind)
587
+ if not isinstance(update, dict):
588
+ raise SettingsError(
589
+ "invalid_settings", f"{kind} update must be a mapping."
590
+ )
591
+ overlay = {k: v for k, v in update.items() if k != "meta"}
592
+ if kind == "text.vars" and "vars" in overlay:
593
+ merged = _deep_merge(current, {k: v for k, v in overlay.items() if k != "vars"})
594
+ merged["vars"] = copy.deepcopy(overlay["vars"])
595
+ else:
596
+ merged = _deep_merge(current, overlay)
597
+ merged["meta"] = {"version": _head_version(kind)}
598
+ return validate_settings(kind, merged)
599
+
600
+
601
+ def merge_writer_maps(base, writes):
602
+ """Merge per-writer maps with one writer per key.
603
+
604
+ Each top-level key may be set by at most one writer; keys no
605
+ writer sets are filled from the base (unset-fill merge).
606
+
607
+ Args:
608
+ base: Baseline mapping supplying unset keys.
609
+ writes: Mapping of writer name to that writer's map.
610
+
611
+ Returns:
612
+ Merged mapping: base plus every writer's keys.
613
+
614
+ Raises:
615
+ SettingsError: invalid_settings for a non-mapping base or
616
+ writer map, writer_conflict when two writers set the
617
+ same key (both writers named).
618
+ """
619
+ if not isinstance(base, dict):
620
+ raise SettingsError(
621
+ "invalid_settings", "Merge base must be a mapping."
622
+ )
623
+ if not isinstance(writes, dict):
624
+ raise SettingsError(
625
+ "invalid_settings", "Writer maps must be a mapping."
626
+ )
627
+ merged = copy.deepcopy(base)
628
+ owners = {}
629
+ for writer, mapping in writes.items():
630
+ if not isinstance(mapping, dict):
631
+ raise SettingsError(
632
+ "invalid_settings", f"Writer {writer!r} set no mapping."
633
+ )
634
+ for key, value in mapping.items():
635
+ if key in owners:
636
+ raise SettingsError(
637
+ "writer_conflict",
638
+ f"Key {key!r} set by both {owners[key]!r} "
639
+ f"and {writer!r}.",
640
+ )
641
+ owners[key] = writer
642
+ merged[key] = copy.deepcopy(value)
643
+ return merged
644
+
645
+
646
+ def resolve_library(tables, name, roots):
647
+ """Resolve one lib.tables entry to a path (read-only, pure read).
648
+
649
+ lib.tables is resolve-only: refs point at libraries the store
650
+ never writes. Absolute refs resolve in place; relative refs try
651
+ each root in order. No probing writes anything anywhere.
652
+
653
+ Args:
654
+ tables: lib.tables settings mapping.
655
+ name: Logical library name to resolve.
656
+ roots: Candidate root directories for relative refs.
657
+
658
+ Returns:
659
+ Path of the first existing referent.
660
+
661
+ Raises:
662
+ SettingsError: unknown_library for an uncataloged name, an
663
+ absolute ref that does not exist, or a relative ref
664
+ found under no root.
665
+ """
666
+ entries = tables.get("tables") if isinstance(tables, dict) else None
667
+ entry = entries.get(name) if isinstance(entries, dict) else None
668
+ ref = entry.get("ref") if isinstance(entry, dict) else None
669
+ if not _is_nonempty_str(ref):
670
+ raise SettingsError(
671
+ "unknown_library", f"No library cataloged as {name!r}."
672
+ )
673
+ if os.path.isabs(ref):
674
+ candidate = Path(ref)
675
+ if candidate.exists():
676
+ return candidate
677
+ raise SettingsError(
678
+ "unknown_library", f"Library {name!r} ref missing: {ref}."
679
+ )
680
+ for root in roots:
681
+ candidate = Path(root) / ref
682
+ if candidate.exists():
683
+ return candidate
684
+ raise SettingsError(
685
+ "unknown_library", f"Library {name!r} ref missing: {ref}."
686
+ )
687
+
688
+
689
+ class SettingsFile:
690
+ """One settings file on disk plus its load-time baseline.
691
+
692
+ Args:
693
+ kind: Settings kind (one of CURRENT_VERSIONS).
694
+ path: File the bytes were read from.
695
+ data: Parsed settings mapping.
696
+ original_text: Exact bytes read (byte-compare baseline).
697
+ future: True when the file version is newer than the head —
698
+ reads are allowed, saves are refused.
699
+ """
700
+
701
+ def __init__(self, kind, path, data, original_text, future):
702
+ self.kind = kind
703
+ self.path = Path(path)
704
+ self.data = data
705
+ self.original_text = original_text
706
+ self.future = future
707
+
708
+
709
+ def load_settings_file(path, kind=None):
710
+ """Load one settings file with its byte baseline.
711
+
712
+ Args:
713
+ path: Settings file to read.
714
+ kind: Settings kind override; derived from the
715
+ `<kind>.yaml` filename when omitted.
716
+
717
+ Returns:
718
+ SettingsFile with parsed data and the original text; future
719
+ files load with future=True for inspection.
720
+
721
+ Raises:
722
+ SettingsError: unknown_kind for an uncataloged kind,
723
+ missing_file for an absent path, invalid_settings for
724
+ unreadable YAML or a non-mapping top without an
725
+ integer meta.version.
726
+ """
727
+ path = Path(path)
728
+ if kind is None:
729
+ stem = path.name
730
+ stem = stem[: -len(".yaml")] if stem.endswith(".yaml") else stem
731
+ kind = stem
732
+ _require_kind(kind)
733
+ if not path.is_file():
734
+ raise SettingsError("missing_file", f"No settings file: {path}.")
735
+ try:
736
+ text = path.read_text(encoding="utf-8")
737
+ except OSError as exc:
738
+ raise SettingsError(
739
+ "invalid_settings", f"Cannot read settings file {path}: {exc}."
740
+ ) from exc
741
+ try:
742
+ data = YAML(typ="safe").load(text)
743
+ except Exception as exc:
744
+ raise SettingsError(
745
+ "invalid_settings", f"Cannot parse settings file {path}: {exc}."
746
+ ) from exc
747
+ if not isinstance(data, dict):
748
+ raise SettingsError(
749
+ "invalid_settings", f"Top level must be a mapping: {path}."
750
+ )
751
+ version = _version_of(kind, data)
752
+ return SettingsFile(
753
+ kind=kind,
754
+ path=path,
755
+ data=data,
756
+ original_text=text,
757
+ future=version > _head_version(kind),
758
+ )
759
+
760
+
761
+ def dump_settings_text(data):
762
+ """Render canonical settings text (byte baseline + doc payload).
763
+
764
+ Args:
765
+ data: Settings mapping to serialize.
766
+
767
+ Returns:
768
+ Canonical YAML text for byte-compare and storage.
769
+ """
770
+ return _dump_yaml(data)
771
+
772
+
773
+ def merge_settings(kind, original, new):
774
+ """Merge new truth onto stored truth, preserving unknown keys.
775
+
776
+ The composition root persists settings through document CRUD,
777
+ so it merges through this seam instead of the file saver: same
778
+ replace-paths, same unknown-key preservation, no I/O.
779
+
780
+ Args:
781
+ kind: Settings kind (one of CURRENT_VERSIONS).
782
+ original: Stored settings mapping, or None for a first
783
+ write.
784
+ new: Complete settings mapping at the head version.
785
+
786
+ Returns:
787
+ Merged mapping ready to validate and store.
788
+
789
+ Raises:
790
+ SettingsError: unknown_kind for an uncataloged kind,
791
+ invalid_settings for stored or new data that breaks
792
+ the kind schema.
793
+ """
794
+ _require_kind(kind)
795
+ validate_settings(kind, new)
796
+ if original is None:
797
+ return copy.deepcopy(new)
798
+ if not isinstance(original, dict):
799
+ raise SettingsError(
800
+ "invalid_settings", f"Stored {kind} settings must be a mapping."
801
+ )
802
+ return _merge_onto_original(original, new, _REPLACE_PATHS[kind])
803
+
804
+
805
+ def _atomic_write_text(path, text):
806
+ """Write through a sibling temp file, then rename into place."""
807
+ path.parent.mkdir(parents=True, exist_ok=True)
808
+ fd, tmp = tempfile.mkstemp(
809
+ dir=str(path.parent), prefix=f"{path.name}.", suffix=".tmp"
810
+ )
811
+ try:
812
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
813
+ handle.write(text)
814
+ os.replace(tmp, path)
815
+ except BaseException:
816
+ try:
817
+ os.unlink(tmp)
818
+ except OSError:
819
+ pass
820
+ raise
821
+
822
+
823
+ def save_settings_file(settings_file, data=None):
824
+ """Save one settings file: merge, byte-compare, atomic write.
825
+
826
+ Merges the data onto the original bytes (unknown keys survive),
827
+ skips the write when the bytes are unchanged, and writes
828
+ atomically otherwise. Future files refuse: autosave never
829
+ clobbers a newer format.
830
+
831
+ Args:
832
+ settings_file: Loaded SettingsFile carrying the baseline.
833
+ data: Settings mapping to save; the loaded data when
834
+ omitted.
835
+
836
+ Returns:
837
+ Mapping with written (bool), path (str), and version.
838
+
839
+ Raises:
840
+ SettingsError: future_format for a file newer than the
841
+ head, invalid_settings for data that breaks the kind
842
+ schema.
843
+
844
+ Caution: Rewrites the file in place when bytes differ — but
845
+ never touches a future file, and validation runs before
846
+ any byte is written.
847
+ """
848
+ if settings_file.future:
849
+ raise SettingsError(
850
+ "future_format",
851
+ f"{settings_file.kind} file is newer than this engine "
852
+ "knows; refusing autosave.",
853
+ )
854
+ new_data = settings_file.data if data is None else data
855
+ validate_settings(settings_file.kind, new_data)
856
+ original = _load_yaml(settings_file.original_text, settings_file.path)
857
+ merged = _merge_onto_original(
858
+ original, new_data, _REPLACE_PATHS[settings_file.kind]
859
+ )
860
+ text = _dump_yaml(merged)
861
+ if text == settings_file.original_text:
862
+ return {
863
+ "written": False,
864
+ "path": str(settings_file.path),
865
+ "version": _head_version(settings_file.kind),
866
+ }
867
+ _atomic_write_text(settings_file.path, text)
868
+ settings_file.data = merged
869
+ settings_file.original_text = text
870
+ return {
871
+ "written": True,
872
+ "path": str(settings_file.path),
873
+ "version": _head_version(settings_file.kind),
874
+ }
875
+
876
+
877
+ def snapshot_settings(settings, session_id=None, run_id=None, library_roots=()):
878
+ """Bundle validated per-kind mappings into a resolved snapshot.
879
+
880
+ One call returns all five kinds plus versions, so clients render
881
+ from a single snapshot instead of per-event round-trips. Library
882
+ entries report their ref plus the resolved path (None when
883
+ unresolvable — resolution strictness lives in
884
+ resolve_libraries, never breaks a read).
885
+
886
+ Args:
887
+ settings: Mapping covering all five settings kinds.
888
+ session_id: Client session this snapshot serves.
889
+ run_id: Run this snapshot was resolved for, if any.
890
+ library_roots: Candidate roots for resolving lib refs;
891
+ refs stay unresolved when empty.
892
+
893
+ Returns:
894
+ Snapshot mapping with session_id, run_id, versions,
895
+ settings (all five kinds), and libraries (each with ref
896
+ plus resolved path or None).
897
+
898
+ Raises:
899
+ SettingsError: invalid_settings when a kind is missing
900
+ or a mapping breaks its kind schema.
901
+ """
902
+ if not isinstance(settings, dict) or set(settings) != set(CURRENT_VERSIONS):
903
+ raise SettingsError(
904
+ "invalid_settings", "A resolved snapshot covers all five kinds."
905
+ )
906
+ for kind, data in settings.items():
907
+ validate_settings(kind, data)
908
+ libraries = {}
909
+ for name, entry in settings["lib.tables"].get("tables", {}).items():
910
+ ref = entry.get("ref")
911
+ resolved = None
912
+ if library_roots and _is_nonempty_str(ref):
913
+ try:
914
+ resolved = str(
915
+ resolve_library(settings["lib.tables"], name, library_roots)
916
+ )
917
+ except SettingsError:
918
+ resolved = None
919
+ libraries[name] = {"ref": ref, "resolved": resolved}
920
+ return {
921
+ "session_id": session_id,
922
+ "run_id": run_id,
923
+ "versions": {
924
+ kind: settings[kind]["meta"]["version"] for kind in CURRENT_VERSIONS
925
+ },
926
+ "settings": copy.deepcopy(settings),
927
+ "libraries": libraries,
928
+ }
929
+
930
+
931
+ class SettingsStore:
932
+ """Five settings files under one project root (or in memory).
933
+
934
+ File mode keeps `<root>/settings/<kind>.yaml` with byte-compare
935
+ skip and atomic writes; memory mode (root None) keeps the same
936
+ texts in a private map with identical merge semantics. Loads
937
+ migrate in memory and never write; writes validate first, refuse
938
+ future on-disk files, then merge-onto-original.
939
+
940
+ Args:
941
+ root: Project directory owning the settings files; None
942
+ keeps an isolated in-memory store.
943
+ """
944
+
945
+ def __init__(self, root=None):
946
+ self._root = Path(root) if root is not None else None
947
+ self._memory = {} if root is None else None
948
+
949
+ def _path(self, kind):
950
+ """Filesystem path for one kind (file mode only)."""
951
+ return self._root / SETTINGS_DIRNAME / _FILENAMES[kind]
952
+
953
+ def _read_text(self, kind):
954
+ """Current canonical text for one kind, or None when absent."""
955
+ if self._memory is not None:
956
+ return self._memory.get(kind)
957
+ path = self._path(kind)
958
+ if not path.is_file():
959
+ return None
960
+ return path.read_text(encoding="utf-8")
961
+
962
+ def _store_text(self, kind, text):
963
+ """Persist canonical text through the active backend."""
964
+ if self._memory is not None:
965
+ self._memory[kind] = text
966
+ return None
967
+ path = self._path(kind)
968
+ _atomic_write_text(path, text)
969
+ return str(path)
970
+
971
+ def ensure_fresh(self, name, pdk_ref, pdk_version, canvases=()):
972
+ """Create missing settings files from fresh project truth.
973
+
974
+ Existing files are never touched: freshness fills gaps,
975
+ never clobbers.
976
+
977
+ Args:
978
+ name: Project/chip name for the manifest.
979
+ pdk_ref: PDK reference (e.g. sky130A).
980
+ pdk_version: PDK version string.
981
+ canvases: Canvas names or {name, ...} mappings.
982
+
983
+ Returns:
984
+ Sorted list of kinds created by this call.
985
+
986
+ Raises:
987
+ SettingsError: invalid_settings for a blank name, PDK
988
+ ref/version, or a canvas without a name.
989
+
990
+ Caution: Writes files for every missing kind — but only
991
+ for missing kinds; existing truth is left byte-alone.
992
+ """
993
+ fresh = fresh_project_settings(name, pdk_ref, pdk_version, canvases)
994
+ created = []
995
+ for kind, data in fresh.items():
996
+ if self._read_text(kind) is None:
997
+ self._store_text(kind, _dump_yaml(data))
998
+ created.append(kind)
999
+ return sorted(created)
1000
+
1001
+ def load(self, kind):
1002
+ """Load one kind, migrated in memory to the head version.
1003
+
1004
+ Loads never write: migration lands in the returned mapping,
1005
+ and the next write persists it. Future on-disk files fail
1006
+ instead of serving truth this engine cannot validate.
1007
+
1008
+ Args:
1009
+ kind: Settings kind (one of CURRENT_VERSIONS).
1010
+
1011
+ Returns:
1012
+ Valid settings mapping at the known head version.
1013
+
1014
+ Raises:
1015
+ SettingsError: unknown_kind for an uncataloged kind,
1016
+ missing_file for a kind with no stored text,
1017
+ future_format for a file newer than the head,
1018
+ missing_migration when no link covers the stored
1019
+ version, invalid_settings for unparsable text.
1020
+ """
1021
+ _require_kind(kind)
1022
+ text = self._read_text(kind)
1023
+ if text is None:
1024
+ raise SettingsError(
1025
+ "missing_file", f"No stored settings for {kind}."
1026
+ )
1027
+ data = _load_yaml(text, kind)
1028
+ version = _version_of(kind, data)
1029
+ head = _head_version(kind)
1030
+ if version > head:
1031
+ raise SettingsError(
1032
+ "future_format",
1033
+ f"{kind} is at version {version}; this engine knows {head}.",
1034
+ )
1035
+ if version < head:
1036
+ return migrate(kind, data)
1037
+ return validate_settings(kind, copy.deepcopy(data))
1038
+
1039
+ def write(self, kind, data):
1040
+ """Replace one kind's truth, preserving unknown keys.
1041
+
1042
+ Validates before touching anything, refuses when the stored
1043
+ file is newer than the head, merges onto the stored bytes,
1044
+ skips the write when bytes are unchanged, and writes
1045
+ atomically otherwise.
1046
+
1047
+ Args:
1048
+ kind: Settings kind (one of CURRENT_VERSIONS).
1049
+ data: Complete settings mapping at the head version.
1050
+
1051
+ Returns:
1052
+ Mapping with written (bool), path (str, None for the
1053
+ memory backend), and version.
1054
+
1055
+ Raises:
1056
+ SettingsError: unknown_kind for an uncataloged kind,
1057
+ invalid_settings for data that breaks the kind
1058
+ schema, future_format when the stored file is
1059
+ newer than the head.
1060
+
1061
+ Caution: Rewrites the stored file when bytes differ — full
1062
+ replacement of data maps (deletes propagate), unknown
1063
+ extension keys preserved, future files never touched.
1064
+ """
1065
+ _require_kind(kind)
1066
+ validate_settings(kind, data)
1067
+ stored = self._read_text(kind)
1068
+ if stored is not None:
1069
+ original = _load_yaml(stored, kind)
1070
+ if _version_of(kind, original) > _head_version(kind):
1071
+ raise SettingsError(
1072
+ "future_format",
1073
+ f"{kind} file is newer than this engine knows; "
1074
+ "refusing autosave.",
1075
+ )
1076
+ merged = _merge_onto_original(
1077
+ original, data, _REPLACE_PATHS[kind]
1078
+ )
1079
+ else:
1080
+ merged = copy.deepcopy(data)
1081
+ text = _dump_yaml(merged)
1082
+ if stored is not None and text == stored:
1083
+ return {
1084
+ "written": False,
1085
+ "path": str(self._path(kind)) if self._root else None,
1086
+ "version": _head_version(kind),
1087
+ }
1088
+ path = self._store_text(kind, text)
1089
+ return {"written": True, "path": path, "version": _head_version(kind)}
1090
+
1091
+ def update(self, kind, patch):
1092
+ """Patch one kind's truth (text.vars deletes propagate).
1093
+
1094
+ Args:
1095
+ kind: Settings kind (one of CURRENT_VERSIONS).
1096
+ patch: Patch mapping (see apply_update).
1097
+
1098
+ Returns:
1099
+ Save-result mapping (see write).
1100
+
1101
+ Raises:
1102
+ SettingsError: unknown_kind for an uncataloged kind,
1103
+ missing_file for a kind with no stored truth,
1104
+ invalid_settings for a patch that breaks the kind
1105
+ schema, future_format when the stored file is
1106
+ newer than the head.
1107
+
1108
+ Caution: Persists the patched truth — text.vars patches
1109
+ carrying `vars` delete omitted keys.
1110
+ """
1111
+ return self.write(kind, apply_update(kind, self.load(kind), patch))
1112
+
1113
+ def merge_writes(self, kind, writes):
1114
+ """Merge one-writer-per-key maps onto one kind's truth.
1115
+
1116
+ Args:
1117
+ kind: Settings kind (one of CURRENT_VERSIONS).
1118
+ writes: Mapping of writer name to that writer's map.
1119
+
1120
+ Returns:
1121
+ Save-result mapping (see write).
1122
+
1123
+ Raises:
1124
+ SettingsError: unknown_kind for an uncataloged kind,
1125
+ missing_file for a kind with no stored truth,
1126
+ writer_conflict when two writers set the same key,
1127
+ invalid_settings for a merged map that breaks the
1128
+ kind schema.
1129
+
1130
+ Caution: Persists the merged truth.
1131
+ """
1132
+ return self.write(
1133
+ kind, merge_writer_maps(self.load(kind), writes)
1134
+ )
1135
+
1136
+ def resolve_libraries(self, roots):
1137
+ """Resolve every lib.tables entry against candidate roots.
1138
+
1139
+ Args:
1140
+ roots: Candidate root directories for relative refs.
1141
+
1142
+ Returns:
1143
+ Mapping of logical library name to resolved Path.
1144
+
1145
+ Raises:
1146
+ SettingsError: missing_file when lib.tables has no
1147
+ stored truth, unknown_library for the first entry
1148
+ found under no root — strict, no silent skip.
1149
+ """
1150
+ tables = self.load("lib.tables")
1151
+ return {
1152
+ name: resolve_library(tables, name, roots)
1153
+ for name in tables.get("tables", {})
1154
+ }
1155
+
1156
+ def snapshot(self, session_id=None, run_id=None, library_roots=()):
1157
+ """Bundle the resolved config for one session/run fetch.
1158
+
1159
+ One call returns all five kinds plus versions, so clients
1160
+ render from a single snapshot instead of per-event
1161
+ round-trips. Library entries report their ref plus the
1162
+ resolved path (None when unresolvable — resolution strictness
1163
+ lives in resolve_libraries, never breaks a read).
1164
+
1165
+ Args:
1166
+ session_id: Client session this snapshot serves.
1167
+ run_id: Run this snapshot was resolved for, if any.
1168
+ library_roots: Candidate roots for resolving lib refs;
1169
+ refs stay unresolved when empty.
1170
+
1171
+ Returns:
1172
+ Snapshot mapping with session_id, run_id, versions,
1173
+ settings (all five kinds), and libraries (each with ref
1174
+ plus resolved path or None).
1175
+
1176
+ Raises:
1177
+ SettingsError: missing_file when any kind has no stored
1178
+ truth, future_format when any stored file is newer
1179
+ than the head, invalid_settings when stored truth
1180
+ breaks its kind schema.
1181
+ """
1182
+ settings = {kind: self.load(kind) for kind in CURRENT_VERSIONS}
1183
+ return snapshot_settings(
1184
+ settings,
1185
+ session_id=session_id,
1186
+ run_id=run_id,
1187
+ library_roots=library_roots,
1188
+ )