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.
- rocket_engine/__init__.py +8 -0
- rocket_engine/__main__.py +12 -0
- rocket_engine/adapters/README.md +54 -0
- rocket_engine/adapters/__init__.py +7 -0
- rocket_engine/adapters/api.py +39 -0
- rocket_engine/adapters/base.py +46 -0
- rocket_engine/adapters/cace/__init__.py +163 -0
- rocket_engine/adapters/circulax/README.md +10 -0
- rocket_engine/adapters/circulax/__init__.py +112 -0
- rocket_engine/adapters/circulax/_driver.py +190 -0
- rocket_engine/adapters/circulax/adapter.py +538 -0
- rocket_engine/adapters/circulax/results.py +291 -0
- rocket_engine/adapters/cli/README.md +34 -0
- rocket_engine/adapters/cli/__init__.py +305 -0
- rocket_engine/adapters/connectivity/__init__.py +196 -0
- rocket_engine/adapters/connectivity/results.py +214 -0
- rocket_engine/adapters/cosim/README.md +9 -0
- rocket_engine/adapters/cosim/__init__.py +1390 -0
- rocket_engine/adapters/cosim/results.py +214 -0
- rocket_engine/adapters/devsim/__init__.py +171 -0
- rocket_engine/adapters/digital_flow/README.md +9 -0
- rocket_engine/adapters/digital_flow/__init__.py +1121 -0
- rocket_engine/adapters/digital_flow/results.py +227 -0
- rocket_engine/adapters/digital_flow/state.py +462 -0
- rocket_engine/adapters/drc/README.klayout.md +7 -0
- rocket_engine/adapters/drc/README.md +7 -0
- rocket_engine/adapters/drc/__init__.py +1 -0
- rocket_engine/adapters/drc/klayout.py +952 -0
- rocket_engine/adapters/drc/magic.py +287 -0
- rocket_engine/adapters/drc/results.py +184 -0
- rocket_engine/adapters/drc/results_kl.py +188 -0
- rocket_engine/adapters/elmer/__init__.py +163 -0
- rocket_engine/adapters/envelope.py +120 -0
- rocket_engine/adapters/equivalence/__init__.py +156 -0
- rocket_engine/adapters/errors.py +181 -0
- rocket_engine/adapters/extract_magic/README.md +15 -0
- rocket_engine/adapters/extract_magic/__init__.py +425 -0
- rocket_engine/adapters/extract_magic/results.py +209 -0
- rocket_engine/adapters/formal_sby/README.md +7 -0
- rocket_engine/adapters/formal_sby/__init__.py +773 -0
- rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
- rocket_engine/adapters/formal_sby/results.py +185 -0
- rocket_engine/adapters/gdsfactory/__init__.py +194 -0
- rocket_engine/adapters/gdsfactory/_driver.py +459 -0
- rocket_engine/adapters/gdsfactory/adapter.py +609 -0
- rocket_engine/adapters/gdsfactory/results.py +181 -0
- rocket_engine/adapters/hdl_sim/README.md +7 -0
- rocket_engine/adapters/hdl_sim/__init__.py +369 -0
- rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
- rocket_engine/adapters/hdl_sim/results.py +178 -0
- rocket_engine/adapters/hdl_sim/verilator.py +167 -0
- rocket_engine/adapters/http/README.md +15 -0
- rocket_engine/adapters/http/__init__.py +458 -0
- rocket_engine/adapters/http/events.py +63 -0
- rocket_engine/adapters/ipc/README.md +9 -0
- rocket_engine/adapters/ipc/__init__.py +111 -0
- rocket_engine/adapters/lvs_netgen/README.md +20 -0
- rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
- rocket_engine/adapters/lvs_netgen/results.py +168 -0
- rocket_engine/adapters/mcp/README.md +19 -0
- rocket_engine/adapters/mcp/__init__.py +85 -0
- rocket_engine/adapters/mcp/api-schema.json +6107 -0
- rocket_engine/adapters/mcp/pull.py +246 -0
- rocket_engine/adapters/mcp/server.py +794 -0
- rocket_engine/adapters/meep/__init__.py +167 -0
- rocket_engine/adapters/meow/README.md +7 -0
- rocket_engine/adapters/meow/__init__.py +190 -0
- rocket_engine/adapters/meow/_driver.py +227 -0
- rocket_engine/adapters/meow/_envelope.py +108 -0
- rocket_engine/adapters/meow/adapter.py +491 -0
- rocket_engine/adapters/meow/results.py +218 -0
- rocket_engine/adapters/mutation/__init__.py +192 -0
- rocket_engine/adapters/ngspice/README.md +28 -0
- rocket_engine/adapters/ngspice/__init__.py +627 -0
- rocket_engine/adapters/ngspice/results.py +215 -0
- rocket_engine/adapters/postlayout/README.md +28 -0
- rocket_engine/adapters/postlayout/__init__.py +17 -0
- rocket_engine/adapters/postlayout/adapter.py +1062 -0
- rocket_engine/adapters/postlayout/results.py +330 -0
- rocket_engine/adapters/postlayout/translate.py +71 -0
- rocket_engine/adapters/profile.py +220 -0
- rocket_engine/adapters/sax/README.md +23 -0
- rocket_engine/adapters/sax/__init__.py +516 -0
- rocket_engine/adapters/sax/_driver.py +148 -0
- rocket_engine/adapters/sax/results.py +291 -0
- rocket_engine/adapters/stdio_console.py +1151 -0
- rocket_engine/adapters/syntax/README.md +5 -0
- rocket_engine/adapters/syntax/__init__.py +366 -0
- rocket_engine/adapters/syntax/results.py +181 -0
- rocket_engine/adapters/synth_yosys/README.md +7 -0
- rocket_engine/adapters/synth_yosys/__init__.py +655 -0
- rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
- rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
- rocket_engine/adapters/synth_yosys/results.py +173 -0
- rocket_engine/adapters/timing_sta/README.md +9 -0
- rocket_engine/adapters/timing_sta/__init__.py +726 -0
- rocket_engine/adapters/view_sync/__init__.py +185 -0
- rocket_engine/adapters/view_sync/results.py +189 -0
- rocket_engine/adapters/vlsir/__init__.py +142 -0
- rocket_engine/adapters/xschem/__init__.py +440 -0
- rocket_engine/adapters/xschem/deck.py +266 -0
- rocket_engine/clients/__init__.py +5 -0
- rocket_engine/clients/runtime.py +255 -0
- rocket_engine/composition.py +2279 -0
- rocket_engine/execution/README.md +30 -0
- rocket_engine/execution/__init__.py +150 -0
- rocket_engine/execution/apig/__init__.py +1 -0
- rocket_engine/execution/apig/pool.py +463 -0
- rocket_engine/execution/apig/submit.py +124 -0
- rocket_engine/execution/block_runs_scope.py +479 -0
- rocket_engine/execution/coalesce.py +414 -0
- rocket_engine/execution/console/__init__.py +1 -0
- rocket_engine/execution/console/apply_session.py +318 -0
- rocket_engine/execution/console/lifecycle.py +265 -0
- rocket_engine/execution/dispatch.py +205 -0
- rocket_engine/execution/errors.py +31 -0
- rocket_engine/execution/ports.py +797 -0
- rocket_engine/execution/pyexec/__init__.py +1 -0
- rocket_engine/execution/pyexec/apply_context.py +547 -0
- rocket_engine/execution/pyexec/approvals.py +173 -0
- rocket_engine/execution/pyexec/runner.py +404 -0
- rocket_engine/execution/pyexec/support.py +464 -0
- rocket_engine/execution/python_exec.py +897 -0
- rocket_engine/execution/queue.py +85 -0
- rocket_engine/execution/replay_safe.py +601 -0
- rocket_engine/execution/routing/__init__.py +1 -0
- rocket_engine/execution/routing/calls.py +364 -0
- rocket_engine/execution/routing/namespace.py +785 -0
- rocket_engine/execution/routing/ports.py +102 -0
- rocket_engine/execution/routing/tools.py +346 -0
- rocket_engine/execution/runner.py +311 -0
- rocket_engine/execution/runtime.py +1149 -0
- rocket_engine/execution/service.py +279 -0
- rocket_engine/execution/worker.py +464 -0
- rocket_engine/interfaces/README.md +26 -0
- rocket_engine/interfaces/__init__.py +58 -0
- rocket_engine/interfaces/apig_assets.py +407 -0
- rocket_engine/interfaces/celllink.py +461 -0
- rocket_engine/interfaces/documents.py +92 -0
- rocket_engine/interfaces/envelopes.py +131 -0
- rocket_engine/interfaces/errors.py +16 -0
- rocket_engine/interfaces/layout.py +410 -0
- rocket_engine/interfaces/layout_schema.yaml +278 -0
- rocket_engine/interfaces/paths.py +223 -0
- rocket_engine/interfaces/pdks.py +550 -0
- rocket_engine/interfaces/ports.py +72 -0
- rocket_engine/interfaces/references.py +115 -0
- rocket_engine/interfaces/refs.py +85 -0
- rocket_engine/interfaces/runparams.py +304 -0
- rocket_engine/interfaces/setup.py +552 -0
- rocket_engine/interfaces/setup_catalogue.yaml +243 -0
- rocket_engine/interfaces/setup_probes.py +66 -0
- rocket_engine/interfaces/vocabulary.py +58 -0
- rocket_engine/interfaces/yaml_dump.py +31 -0
- rocket_engine/library/README.md +22 -0
- rocket_engine/library/__init__.py +13 -0
- rocket_engine/library/apig_catalog.py +371 -0
- rocket_engine/library/catalog.py +185 -0
- rocket_engine/library/errors.py +16 -0
- rocket_engine/mutate/__init__.py +776 -0
- rocket_engine/mutate/operators.py +700 -0
- rocket_engine/ops/__init__.py +1 -0
- rocket_engine/ops/deferred.py +279 -0
- rocket_engine/ops/editors.py +404 -0
- rocket_engine/ops/evidence.py +194 -0
- rocket_engine/ops/gates.py +262 -0
- rocket_engine/ops/ports.py +76 -0
- rocket_engine/ops/queries.py +417 -0
- rocket_engine/ops/runs.py +553 -0
- rocket_engine/ops/submit.py +614 -0
- rocket_engine/project/README.md +27 -0
- rocket_engine/project/__init__.py +6 -0
- rocket_engine/project/canvas/__init__.py +55 -0
- rocket_engine/project/canvas/compile.py +109 -0
- rocket_engine/project/canvas/integrate.py +48 -0
- rocket_engine/project/canvas/schema.py +241 -0
- rocket_engine/project/canvas/templates.py +46 -0
- rocket_engine/project/canvas/validate.py +603 -0
- rocket_engine/project/edit/__init__.py +5 -0
- rocket_engine/project/edit/cas.py +87 -0
- rocket_engine/project/edit/transactions.py +282 -0
- rocket_engine/project/errors.py +16 -0
- rocket_engine/project/frontend13.py +685 -0
- rocket_engine/project/geometry/__init__.py +23 -0
- rocket_engine/project/geometry/ops.py +451 -0
- rocket_engine/project/geometry/snap.py +103 -0
- rocket_engine/project/ip/__init__.py +1 -0
- rocket_engine/project/ip/blocks.py +195 -0
- rocket_engine/project/ip/entry.schema.json +78 -0
- rocket_engine/project/ip/lifecycle.py +402 -0
- rocket_engine/project/ip/verbs.py +123 -0
- rocket_engine/project/layers/__init__.py +6 -0
- rocket_engine/project/layers/bindings.py +115 -0
- rocket_engine/project/layers/catalog.py +228 -0
- rocket_engine/project/layers/presets.py +43 -0
- rocket_engine/project/ledger/__init__.py +1 -0
- rocket_engine/project/ledger/docs.py +167 -0
- rocket_engine/project/ledger/entities.py +176 -0
- rocket_engine/project/ledger/events.py +29 -0
- rocket_engine/project/ledger/runs.py +257 -0
- rocket_engine/project/ledger/shared.py +174 -0
- rocket_engine/project/ledger/snapshots.py +385 -0
- rocket_engine/project/ledger/wires.py +552 -0
- rocket_engine/project/process/__init__.py +14 -0
- rocket_engine/project/process/model.py +193 -0
- rocket_engine/project/process/recipes.py +121 -0
- rocket_engine/project/schematic/__init__.py +1 -0
- rocket_engine/project/schematic/hierarchy.py +312 -0
- rocket_engine/project/schematic/instances.py +393 -0
- rocket_engine/project/schematic/sheets.py +198 -0
- rocket_engine/project/service.py +2941 -0
- rocket_engine/project/symbols/__init__.py +1 -0
- rocket_engine/project/symbols/cells.py +324 -0
- rocket_engine/project/symbols/linkage.py +356 -0
- rocket_engine/project/symbols/symbols.py +352 -0
- rocket_engine/project/templates/__init__.py +303 -0
- rocket_engine/project/templates/errors.py +25 -0
- rocket_engine/project/testbench/__init__.py +49 -0
- rocket_engine/project/testbench/blocks.py +619 -0
- rocket_engine/project/testbench/templates.py +134 -0
- rocket_engine/stores/README.md +12 -0
- rocket_engine/stores/__init__.py +6 -0
- rocket_engine/stores/ecs.py +1508 -0
- rocket_engine/stores/layers.py +1023 -0
- rocket_engine/stores/settings.py +1188 -0
- rocket_engine/stores/wireplace.py +1324 -0
- rocket_engine/workflows/README.md +18 -0
- rocket_engine/workflows/__init__.py +53 -0
- rocket_engine/workflows/capability_plan.py +102 -0
- rocket_engine/workflows/completion.py +228 -0
- rocket_engine/workflows/connectgraph.py +734 -0
- rocket_engine/workflows/connectgraph_emit.py +336 -0
- rocket_engine/workflows/gates.py +144 -0
- rocket_engine/workflows/photonic_wire.py +76 -0
- rocket_engine/workflows/stages.py +324 -0
- rocket_engine/workflows/viewsync.py +399 -0
- rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
- rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
- rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
- rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
- rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""Canvas loading, validation, closure hashing, and emission.
|
|
2
|
+
|
|
3
|
+
A validated canvas is the design intent the compiler (COMPILE
|
|
4
|
+
slice) consumes: this module proves the file exists, is
|
|
5
|
+
versioned, names only known metrics and knobs with checked
|
|
6
|
+
units, pins its PDK revisions, and hashes its delegated content
|
|
7
|
+
into the run closure. Anything violating a rule fails loudly
|
|
8
|
+
naming the rule and the section — never silent, never a merge.
|
|
9
|
+
|
|
10
|
+
Seam: `load_project_canvas` reads and validates the project
|
|
11
|
+
`canvas.toml` but does not hook project creation or submit
|
|
12
|
+
validation — the adapt lane wires those call sites (and the I28
|
|
13
|
+
run closure) through `validate_canvas`/`canvas_closure`. The
|
|
14
|
+
`[meta]` section from early ticket prose is superseded by the
|
|
15
|
+
frozen contract: `technology`/`pdks` carry the revision pin, and
|
|
16
|
+
unknown top-level sections are rejected.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
import tomllib
|
|
23
|
+
from pathlib import Path, PurePosixPath
|
|
24
|
+
|
|
25
|
+
from ...interfaces.paths import PathError, join_contained
|
|
26
|
+
from .schema import (
|
|
27
|
+
GENERIC_TOOL_KNOBS,
|
|
28
|
+
HDL_LANGUAGES,
|
|
29
|
+
KNOWN_METRICS,
|
|
30
|
+
KNOWN_UNITS,
|
|
31
|
+
OPERATORS,
|
|
32
|
+
REF_SCOPES,
|
|
33
|
+
TOOL_KNOBS,
|
|
34
|
+
ui_schema,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
CANVAS_FILENAME = "canvas.toml"
|
|
38
|
+
LEGACY_FILENAME = "canvas.cst"
|
|
39
|
+
CANVAS_VERSION = "rocketeda/canvas/v1"
|
|
40
|
+
|
|
41
|
+
_TOP_LEVEL = frozenset(
|
|
42
|
+
{"schema", "hdl", "technology", "pdks", "targets", "tools",
|
|
43
|
+
"references"}
|
|
44
|
+
)
|
|
45
|
+
_REQUIRED_TOP = ("schema", "pdks", "targets", "tools")
|
|
46
|
+
|
|
47
|
+
CANVAS_SCHEMA = ui_schema()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CanvasError(Exception):
|
|
51
|
+
"""Canvas violation with a stable machine code.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
code: Stable machine-readable rule code.
|
|
55
|
+
message: Human detail naming the rule and the section.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, code, message):
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.code = code
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fail(code, section, detail):
|
|
64
|
+
raise CanvasError(code, f"canvas.{section}: {detail}.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _need_mapping(value, section, code="bad_shape"):
|
|
68
|
+
if not isinstance(value, dict):
|
|
69
|
+
_fail(code, section, f"must be a mapping, got {value!r}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check_ref(ref, section, require_revision=True):
|
|
73
|
+
_need_mapping(ref, section, code="bad_ref")
|
|
74
|
+
scope = ref.get("scope")
|
|
75
|
+
if scope not in REF_SCOPES:
|
|
76
|
+
_fail("bad_ref", section,
|
|
77
|
+
f"scope must be one of {sorted(REF_SCOPES)}, "
|
|
78
|
+
f"got {scope!r} (rule ref-shape)")
|
|
79
|
+
ident = ref.get("id")
|
|
80
|
+
if not isinstance(ident, str) or not ident:
|
|
81
|
+
_fail("bad_ref", section,
|
|
82
|
+
"id must be a non-empty string (rule ref-shape)")
|
|
83
|
+
revision = ref.get("revision")
|
|
84
|
+
if scope == "project" and revision is None:
|
|
85
|
+
project = ref.get("project_id")
|
|
86
|
+
if not isinstance(project, str) or not project:
|
|
87
|
+
_fail("bad_ref", section,
|
|
88
|
+
"project refs need project_id without a revision "
|
|
89
|
+
"(rule ref-shape)")
|
|
90
|
+
return
|
|
91
|
+
if not isinstance(revision, str) or not revision:
|
|
92
|
+
_fail("unpinned_revision", section,
|
|
93
|
+
"revision must be a non-empty pin (rule revision-pinned)")
|
|
94
|
+
extra = set(ref) - {"scope", "id", "revision", "project_id"}
|
|
95
|
+
if extra:
|
|
96
|
+
_fail("bad_ref", section,
|
|
97
|
+
f"unknown ref keys {sorted(extra)} (rule ref-shape)")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_quantity(value, section, allowed_units=None, note=None):
|
|
101
|
+
_need_mapping(value, section, code="bad_limit")
|
|
102
|
+
tail = f" for {note}" if note else ""
|
|
103
|
+
number = value.get("value")
|
|
104
|
+
if (
|
|
105
|
+
isinstance(number, bool)
|
|
106
|
+
or not isinstance(number, (int, float))
|
|
107
|
+
or not math.isfinite(number)
|
|
108
|
+
):
|
|
109
|
+
_fail("bad_limit", section,
|
|
110
|
+
f"value must be a finite number, got {number!r}{tail} "
|
|
111
|
+
"(rule units-checked)")
|
|
112
|
+
unit = value.get("unit")
|
|
113
|
+
universe = allowed_units if allowed_units is not None else KNOWN_UNITS
|
|
114
|
+
if not isinstance(unit, str) or unit not in universe:
|
|
115
|
+
_fail("bad_unit", section,
|
|
116
|
+
f"unit {unit!r}{tail} not in {sorted(universe)} "
|
|
117
|
+
"(rule units-checked)")
|
|
118
|
+
extra = set(value) - {"value", "unit"}
|
|
119
|
+
if extra:
|
|
120
|
+
_fail("bad_limit", section,
|
|
121
|
+
f"unknown limit keys {sorted(extra)} (rule units-checked)")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check_target(target, index):
|
|
125
|
+
section = f"targets[{index}]"
|
|
126
|
+
_need_mapping(target, section, code="bad_target")
|
|
127
|
+
metric = target.get("metric")
|
|
128
|
+
if metric not in KNOWN_METRICS:
|
|
129
|
+
_fail("unknown_metric", f"{section}.metric",
|
|
130
|
+
f"unknown metric {metric!r}; register it in "
|
|
131
|
+
"schema.KNOWN_METRICS (rule known-metrics)")
|
|
132
|
+
operator = target.get("operator")
|
|
133
|
+
if operator not in OPERATORS:
|
|
134
|
+
_fail("bad_operator", f"{section}.operator",
|
|
135
|
+
f"operator must be one of {list(OPERATORS)}, "
|
|
136
|
+
f"got {operator!r} (rule known-operators)")
|
|
137
|
+
if "limit" not in target:
|
|
138
|
+
_fail("bad_limit", f"{section}.limit",
|
|
139
|
+
"limit is required (rule units-checked)")
|
|
140
|
+
_, units = KNOWN_METRICS[metric]
|
|
141
|
+
_check_quantity(target["limit"], f"{section}.limit",
|
|
142
|
+
allowed_units=units,
|
|
143
|
+
note=f"metric {metric!r}")
|
|
144
|
+
required = target.get("required", True)
|
|
145
|
+
if not isinstance(required, bool):
|
|
146
|
+
_fail("bad_required", f"{section}.required",
|
|
147
|
+
f"required must be a bool, got {required!r}")
|
|
148
|
+
extra = set(target) - {"metric", "operator", "limit", "required",
|
|
149
|
+
"target"}
|
|
150
|
+
if extra:
|
|
151
|
+
_fail("bad_target", section,
|
|
152
|
+
f"unknown target keys {sorted(extra)}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _check_knob_value(tool, knob, spec, value, section):
|
|
156
|
+
kind = spec["kind"]
|
|
157
|
+
if kind == "ref":
|
|
158
|
+
_check_ref(value, section, require_revision=True)
|
|
159
|
+
elif kind == "text":
|
|
160
|
+
if not isinstance(value, str) or not value:
|
|
161
|
+
_fail("bad_knob", section,
|
|
162
|
+
"must be non-empty text (rule known-knobs)")
|
|
163
|
+
elif kind == "hash":
|
|
164
|
+
if (
|
|
165
|
+
not isinstance(value, str)
|
|
166
|
+
or len(value) != 64
|
|
167
|
+
or any(c not in "0123456789abcdef" for c in value)
|
|
168
|
+
):
|
|
169
|
+
_fail("bad_knob", section,
|
|
170
|
+
"must be a 64-hex sha256 (rule known-knobs)")
|
|
171
|
+
elif kind == "boolean":
|
|
172
|
+
if not isinstance(value, bool):
|
|
173
|
+
_fail("bad_knob", section,
|
|
174
|
+
f"must be a bool, got {value!r} (rule known-knobs)")
|
|
175
|
+
elif kind == "number":
|
|
176
|
+
if (
|
|
177
|
+
isinstance(value, bool)
|
|
178
|
+
or not isinstance(value, (int, float))
|
|
179
|
+
or not math.isfinite(value)
|
|
180
|
+
):
|
|
181
|
+
_fail("bad_knob", section,
|
|
182
|
+
f"must be a finite number, got {value!r} "
|
|
183
|
+
"(rule units-checked)")
|
|
184
|
+
low = spec.get("min_exclusive")
|
|
185
|
+
if low is not None and not value > low:
|
|
186
|
+
_fail("bad_knob", section,
|
|
187
|
+
f"must exceed {low}, got {value} (rule knob-bounds)")
|
|
188
|
+
high = spec.get("max")
|
|
189
|
+
if high is not None and not value <= high:
|
|
190
|
+
_fail("bad_knob", section,
|
|
191
|
+
f"must be at most {high}, got {value} "
|
|
192
|
+
"(rule knob-bounds)")
|
|
193
|
+
elif kind == "integer":
|
|
194
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
195
|
+
_fail("bad_knob", section,
|
|
196
|
+
f"must be an int, got {value!r} (rule known-knobs)")
|
|
197
|
+
if not spec["min"] <= value <= spec["max"]:
|
|
198
|
+
_fail("bad_knob", section,
|
|
199
|
+
f"must be {spec['min']}..{spec['max']}, got "
|
|
200
|
+
f"{value} (rule knob-bounds)")
|
|
201
|
+
elif kind == "enum":
|
|
202
|
+
if value not in spec["values"]:
|
|
203
|
+
_fail("bad_knob", section,
|
|
204
|
+
f"must be one of {list(spec['values'])}, "
|
|
205
|
+
f"got {value!r} (rule known-knobs)")
|
|
206
|
+
elif kind == "string_list":
|
|
207
|
+
if (
|
|
208
|
+
not isinstance(value, list)
|
|
209
|
+
or not value
|
|
210
|
+
or any(not isinstance(item, str) or not item
|
|
211
|
+
for item in value)
|
|
212
|
+
):
|
|
213
|
+
_fail("bad_knob", section,
|
|
214
|
+
"must be a non-empty list of text "
|
|
215
|
+
"(rule known-knobs)")
|
|
216
|
+
elif kind == "quantity":
|
|
217
|
+
_check_quantity(value, section,
|
|
218
|
+
allowed_units=spec.get("units"))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _resolve_contained(project_root, relpath, section):
|
|
222
|
+
if not isinstance(relpath, str) or not relpath:
|
|
223
|
+
_fail("bad_ref", section,
|
|
224
|
+
"path must be non-empty text (rule refs-contained)")
|
|
225
|
+
parsed = PurePosixPath(relpath)
|
|
226
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
227
|
+
_fail("path_escape", section,
|
|
228
|
+
f"path escapes the project: {relpath!r} "
|
|
229
|
+
"(rule refs-contained)")
|
|
230
|
+
try:
|
|
231
|
+
resolved = join_contained(str(project_root), relpath)
|
|
232
|
+
except PathError as exc:
|
|
233
|
+
_fail("path_escape", section,
|
|
234
|
+
f"{relpath!r} escapes the project ({exc}) "
|
|
235
|
+
"(rule refs-contained)")
|
|
236
|
+
if not resolved.is_file():
|
|
237
|
+
_fail("missing_ref", section,
|
|
238
|
+
f"no such project file: {relpath!r} "
|
|
239
|
+
"(rule refs-contained)")
|
|
240
|
+
digest = hashlib.sha256()
|
|
241
|
+
with open(resolved, "rb") as handle:
|
|
242
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
243
|
+
digest.update(chunk)
|
|
244
|
+
return parsed.as_posix(), digest.hexdigest()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _check_tools(tools, project_root, refs):
|
|
248
|
+
_need_mapping(tools, "tools", code="bad_tools")
|
|
249
|
+
for name, entry in tools.items():
|
|
250
|
+
section = f"tools.{name}"
|
|
251
|
+
_need_mapping(entry, section, code="bad_tool")
|
|
252
|
+
known = TOOL_KNOBS.get(name)
|
|
253
|
+
allowed = known if known is not None else {
|
|
254
|
+
knob: {"kind": "generic"} for knob in GENERIC_TOOL_KNOBS
|
|
255
|
+
}
|
|
256
|
+
for knob in entry:
|
|
257
|
+
if knob not in allowed:
|
|
258
|
+
_fail("unknown_knob", f"{section}.{knob}",
|
|
259
|
+
f"unknown knob for tool {name!r}; register it "
|
|
260
|
+
"in schema.TOOL_KNOBS (rule known-knobs)")
|
|
261
|
+
spec = allowed
|
|
262
|
+
for knob, value in entry.items():
|
|
263
|
+
kind = spec[knob]["kind"]
|
|
264
|
+
if kind == "generic":
|
|
265
|
+
if knob == "profile":
|
|
266
|
+
_check_ref(value, section)
|
|
267
|
+
elif knob in ("configuration",):
|
|
268
|
+
if not isinstance(value, str) or not value:
|
|
269
|
+
_fail("bad_knob", section,
|
|
270
|
+
"must be non-empty text")
|
|
271
|
+
elif knob == "sha256":
|
|
272
|
+
_check_knob_value(name, knob, {"kind": "hash"},
|
|
273
|
+
value, section)
|
|
274
|
+
continue
|
|
275
|
+
_check_knob_value(name, knob, spec[knob], value,
|
|
276
|
+
f"{section}.{knob}")
|
|
277
|
+
inline = {
|
|
278
|
+
knob for knob in entry
|
|
279
|
+
if knob not in ("profile", "sha256", "configuration")
|
|
280
|
+
}
|
|
281
|
+
if "configuration" in entry and inline:
|
|
282
|
+
_fail("duplicate_configuration", section,
|
|
283
|
+
f"inline knobs {sorted(inline)} conflict with "
|
|
284
|
+
"delegated configuration; keep one "
|
|
285
|
+
"(rule single-source)")
|
|
286
|
+
if "configuration" in entry:
|
|
287
|
+
rel, digest = _resolve_contained(
|
|
288
|
+
project_root, entry["configuration"], section)
|
|
289
|
+
refs.append({"path": rel, "sha256": digest})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def validate_canvas(doc, project_root):
|
|
293
|
+
"""Validate a parsed canvas document against the schema.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
doc: Parsed `canvas.toml` mapping.
|
|
297
|
+
project_root: Project dir delegated refs resolve inside;
|
|
298
|
+
refs stay contained or fail loud.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Validated config mapping plus the closure: refs (path,
|
|
302
|
+
sha256) and closure_hash pinning the intent.
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
CanvasError: The first rule violated, naming the rule
|
|
306
|
+
and the section (bad_schema, unknown_metric,
|
|
307
|
+
bad_unit, unknown_knob, duplicate_configuration,
|
|
308
|
+
unpinned_revision, path_escape, missing_ref, ...).
|
|
309
|
+
"""
|
|
310
|
+
_need_mapping(doc, "", code="bad_shape")
|
|
311
|
+
unknown = set(doc) - _TOP_LEVEL
|
|
312
|
+
if unknown:
|
|
313
|
+
_fail("unknown_section", sorted(unknown)[0],
|
|
314
|
+
f"unknown top-level section "
|
|
315
|
+
f"(rule schema-closed): {sorted(unknown)}")
|
|
316
|
+
for key in _REQUIRED_TOP:
|
|
317
|
+
if key not in doc:
|
|
318
|
+
_fail("missing_section", key,
|
|
319
|
+
"required by the v1 canvas contract "
|
|
320
|
+
"(rule schema-closed)")
|
|
321
|
+
if doc["schema"] != CANVAS_VERSION:
|
|
322
|
+
_fail("bad_schema", "schema",
|
|
323
|
+
f"must be {CANVAS_VERSION!r}, got "
|
|
324
|
+
f"{doc['schema']!r} (rule schema-version)")
|
|
325
|
+
refs = []
|
|
326
|
+
# An empty table (`[technology]` with only comments) carries no
|
|
327
|
+
# claim, so it reads as absent; anything stated validates fully.
|
|
328
|
+
if doc.get("hdl"):
|
|
329
|
+
_need_mapping(doc["hdl"], "hdl", code="bad_hdl")
|
|
330
|
+
language = doc["hdl"].get("language")
|
|
331
|
+
if language not in HDL_LANGUAGES:
|
|
332
|
+
_fail("bad_hdl", "hdl.language",
|
|
333
|
+
f"language must be one of {list(HDL_LANGUAGES)}, "
|
|
334
|
+
f"got {language!r} (rule one-dialect)")
|
|
335
|
+
top = doc["hdl"].get("top")
|
|
336
|
+
if top is not None and (not isinstance(top, str) or not top):
|
|
337
|
+
_fail("bad_hdl", "hdl.top",
|
|
338
|
+
"top must be non-empty text (rule one-dialect)")
|
|
339
|
+
extra = set(doc["hdl"]) - {"language", "top"}
|
|
340
|
+
if extra:
|
|
341
|
+
_fail("bad_hdl", "hdl",
|
|
342
|
+
f"unknown hdl keys {sorted(extra)}")
|
|
343
|
+
if doc.get("technology"):
|
|
344
|
+
_check_ref(doc["technology"], "technology")
|
|
345
|
+
pdks = doc["pdks"]
|
|
346
|
+
if not isinstance(pdks, list):
|
|
347
|
+
_fail("bad_pdks", "pdks", "must be a list (rule pdk-pinned)")
|
|
348
|
+
for index, ref in enumerate(pdks):
|
|
349
|
+
_check_ref(ref, f"pdks[{index}]")
|
|
350
|
+
targets = doc["targets"]
|
|
351
|
+
if not isinstance(targets, list):
|
|
352
|
+
_fail("bad_targets", "targets",
|
|
353
|
+
"must be a list (rule known-metrics)")
|
|
354
|
+
for index, target in enumerate(targets):
|
|
355
|
+
_check_target(target, index)
|
|
356
|
+
_check_tools(doc["tools"], project_root, refs)
|
|
357
|
+
references = doc.get("references", [])
|
|
358
|
+
if not isinstance(references, list):
|
|
359
|
+
_fail("bad_references", "references",
|
|
360
|
+
"must be a list (rule refs-contained)")
|
|
361
|
+
for index, entry in enumerate(references):
|
|
362
|
+
section = f"references[{index}]"
|
|
363
|
+
_need_mapping(entry, section, code="bad_ref")
|
|
364
|
+
if "path" not in entry or not isinstance(entry["path"], str):
|
|
365
|
+
_fail("bad_ref", section,
|
|
366
|
+
"path is required text (rule refs-contained)")
|
|
367
|
+
rel, digest = _resolve_contained(
|
|
368
|
+
project_root, entry["path"], section)
|
|
369
|
+
pinned = entry.get("sha256")
|
|
370
|
+
if pinned is not None and pinned != digest:
|
|
371
|
+
_fail("ref_changed", section,
|
|
372
|
+
f"{rel!r} changed under its pin "
|
|
373
|
+
f"(rule drift-loud): have {digest}")
|
|
374
|
+
refs.append({"path": rel, "sha256": digest})
|
|
375
|
+
extra = set(entry) - {"path", "sha256"}
|
|
376
|
+
if extra:
|
|
377
|
+
_fail("bad_ref", section,
|
|
378
|
+
f"unknown reference keys {sorted(extra)}")
|
|
379
|
+
config = {
|
|
380
|
+
"schema": doc["schema"],
|
|
381
|
+
"pdks": doc["pdks"],
|
|
382
|
+
"targets": doc["targets"],
|
|
383
|
+
"tools": doc["tools"],
|
|
384
|
+
}
|
|
385
|
+
if doc.get("hdl"):
|
|
386
|
+
config["hdl"] = doc["hdl"]
|
|
387
|
+
if doc.get("technology"):
|
|
388
|
+
config["technology"] = doc["technology"]
|
|
389
|
+
if "references" in doc:
|
|
390
|
+
config["references"] = doc["references"]
|
|
391
|
+
refs.sort(key=lambda entry: entry["path"])
|
|
392
|
+
return {"config": config, "closure": canvas_closure(refs)}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def canvas_closure(refs):
|
|
396
|
+
"""Hash pinned refs into a run-closure identity.
|
|
397
|
+
|
|
398
|
+
The hash is content-addressed and key-order stable, mirroring
|
|
399
|
+
the block-run closure: equal intents hash equally, so a rerun
|
|
400
|
+
after a sibling edit proves isolation by hashing differently.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
refs: Sorted ref entries (path, sha256).
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Closure mapping with refs and closure_hash (hex sha256
|
|
407
|
+
over the canonical JSON encoding).
|
|
408
|
+
"""
|
|
409
|
+
canonical = json.dumps(
|
|
410
|
+
refs, sort_keys=True, separators=(",", ":"), default=str
|
|
411
|
+
)
|
|
412
|
+
return {
|
|
413
|
+
"refs": [dict(entry) for entry in refs],
|
|
414
|
+
"closure_hash": hashlib.sha256(
|
|
415
|
+
canonical.encode("utf-8")).hexdigest(),
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def load_project_canvas(project_root, filename=CANVAS_FILENAME):
|
|
420
|
+
"""Load, validate, and close over a project's canvas file.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
project_root: Project dir holding `canvas.toml`.
|
|
424
|
+
filename: Canvas filename; explicit migration only, so
|
|
425
|
+
the legacy name never loads here.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Mapping with path, config (validated), and closure
|
|
429
|
+
(canvas_sha256 over the raw bytes, refs, and
|
|
430
|
+
closure_hash pinning canvas plus refs).
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
CanvasError: canvas_missing for no canvas file,
|
|
434
|
+
conflicting_canvas when a legacy `canvas.cst` sits
|
|
435
|
+
beside `canvas.toml`, bad_toml for unparsable text,
|
|
436
|
+
or whatever `validate_canvas` raises.
|
|
437
|
+
"""
|
|
438
|
+
root = Path(project_root)
|
|
439
|
+
legacy = root / LEGACY_FILENAME
|
|
440
|
+
if legacy.is_file():
|
|
441
|
+
_fail("conflicting_canvas", LEGACY_FILENAME,
|
|
442
|
+
f"{LEGACY_FILENAME} and {filename} both exist; "
|
|
443
|
+
"migrate explicitly, never both "
|
|
444
|
+
"(rule explicit-migration)")
|
|
445
|
+
path = root / filename
|
|
446
|
+
if not path.is_file():
|
|
447
|
+
_fail("canvas_missing", filename,
|
|
448
|
+
f"no {filename} in {root} (rule canvas-exists)")
|
|
449
|
+
raw = path.read_bytes()
|
|
450
|
+
try:
|
|
451
|
+
doc = tomllib.loads(raw.decode("utf-8"))
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
_fail("bad_toml", filename,
|
|
454
|
+
f"does not parse as TOML ({exc}) (rule toml-shape)")
|
|
455
|
+
validated = validate_canvas(doc, root)
|
|
456
|
+
refs = list(validated["closure"]["refs"])
|
|
457
|
+
closure = dict(validated["closure"])
|
|
458
|
+
closure["canvas_sha256"] = hashlib.sha256(raw).hexdigest()
|
|
459
|
+
closure["closure_hash"] = hashlib.sha256(
|
|
460
|
+
json.dumps(
|
|
461
|
+
{"canvas_sha256": closure["canvas_sha256"],
|
|
462
|
+
"refs": refs},
|
|
463
|
+
sort_keys=True, separators=(",", ":"),
|
|
464
|
+
default=str,
|
|
465
|
+
).encode("utf-8")).hexdigest()
|
|
466
|
+
return {
|
|
467
|
+
"path": str(path),
|
|
468
|
+
"config": validated["config"],
|
|
469
|
+
"closure": closure,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def dump_canvas_toml(doc):
|
|
474
|
+
"""Serialize a canvas mapping to canonical TOML text.
|
|
475
|
+
|
|
476
|
+
The project-creation wizard writes through here, so a filled
|
|
477
|
+
canvas always round-trips through `validate_canvas`.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
doc: Canvas mapping (nested mappings, lists of mappings
|
|
481
|
+
for pdks/targets/references, scalars otherwise).
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
TOML text with `schema` first and stable key order.
|
|
485
|
+
|
|
486
|
+
Raises:
|
|
487
|
+
CanvasError: bad_value for values TOML cannot carry
|
|
488
|
+
(None, NaN/inf, or unknown types).
|
|
489
|
+
"""
|
|
490
|
+
lines = []
|
|
491
|
+
|
|
492
|
+
def emit_scalar(value, section):
|
|
493
|
+
if isinstance(value, bool):
|
|
494
|
+
return "true" if value else "false"
|
|
495
|
+
if isinstance(value, int):
|
|
496
|
+
return str(value)
|
|
497
|
+
if isinstance(value, float):
|
|
498
|
+
if not math.isfinite(value):
|
|
499
|
+
_fail("bad_value", section,
|
|
500
|
+
"TOML cannot carry NaN/inf (rule toml-shape)")
|
|
501
|
+
text = repr(value)
|
|
502
|
+
return text if "." in text or "e" in text else text + ".0"
|
|
503
|
+
if isinstance(value, str):
|
|
504
|
+
escaped = (
|
|
505
|
+
value.replace("\\", "\\\\")
|
|
506
|
+
.replace('"', '\\"')
|
|
507
|
+
.replace("\n", "\\n")
|
|
508
|
+
.replace("\t", "\\t")
|
|
509
|
+
)
|
|
510
|
+
return f'"{escaped}"'
|
|
511
|
+
_fail("bad_value", section,
|
|
512
|
+
f"TOML cannot carry {type(value).__name__} "
|
|
513
|
+
"(rule toml-shape)")
|
|
514
|
+
|
|
515
|
+
def emit_table(mapping, prefix, section):
|
|
516
|
+
# Scalars first: a sub-table header re-points every later
|
|
517
|
+
# key, so parent keys must precede `[table.child]` lines.
|
|
518
|
+
for key in sorted(mapping):
|
|
519
|
+
value = mapping[key]
|
|
520
|
+
if isinstance(value, dict):
|
|
521
|
+
continue
|
|
522
|
+
if isinstance(value, list) and value and all(
|
|
523
|
+
isinstance(item, dict) for item in value
|
|
524
|
+
):
|
|
525
|
+
continue
|
|
526
|
+
if isinstance(value, list):
|
|
527
|
+
items = ", ".join(
|
|
528
|
+
emit_scalar(item, f"{section}.{key}")
|
|
529
|
+
for item in value
|
|
530
|
+
)
|
|
531
|
+
lines.append(f"{key} = [{items}]")
|
|
532
|
+
else:
|
|
533
|
+
lines.append(
|
|
534
|
+
f"{key} = {emit_scalar(value, f'{section}.{key}')}"
|
|
535
|
+
)
|
|
536
|
+
for key in sorted(mapping):
|
|
537
|
+
value = mapping[key]
|
|
538
|
+
if isinstance(value, dict):
|
|
539
|
+
lines.append(f"[{prefix}{key}]")
|
|
540
|
+
emit_table(value, f"{prefix}{key}.",
|
|
541
|
+
f"{section}.{key}")
|
|
542
|
+
elif isinstance(value, list) and value and all(
|
|
543
|
+
isinstance(item, dict) for item in value
|
|
544
|
+
):
|
|
545
|
+
for item in value:
|
|
546
|
+
lines.append(f"[[{prefix}{key}]]")
|
|
547
|
+
emit_table(item, f"{prefix}{key}.",
|
|
548
|
+
f"{section}.{key}")
|
|
549
|
+
|
|
550
|
+
top_scalars = {}
|
|
551
|
+
top_tables = {}
|
|
552
|
+
for key in doc:
|
|
553
|
+
value = doc[key]
|
|
554
|
+
# Empty lists are scalars (`key = []`); emitting them after
|
|
555
|
+
# a `[table]` header would re-point them into that table.
|
|
556
|
+
if isinstance(value, dict) or (
|
|
557
|
+
isinstance(value, list) and value and all(
|
|
558
|
+
isinstance(item, dict) for item in value
|
|
559
|
+
)
|
|
560
|
+
):
|
|
561
|
+
top_tables[key] = value
|
|
562
|
+
else:
|
|
563
|
+
top_scalars[key] = value
|
|
564
|
+
ordered = {}
|
|
565
|
+
if "schema" in top_scalars:
|
|
566
|
+
ordered["schema"] = top_scalars.pop("schema")
|
|
567
|
+
ordered.update(sorted(top_scalars.items()))
|
|
568
|
+
for key, value in ordered.items():
|
|
569
|
+
if isinstance(value, list):
|
|
570
|
+
items = ", ".join(
|
|
571
|
+
emit_scalar(item, key) for item in value
|
|
572
|
+
)
|
|
573
|
+
lines.append(f"{key} = [{items}]")
|
|
574
|
+
else:
|
|
575
|
+
lines.append(f"{key} = {emit_scalar(value, key)}")
|
|
576
|
+
if ordered and top_tables:
|
|
577
|
+
lines.append("")
|
|
578
|
+
first = True
|
|
579
|
+
order = ["hdl", "technology", "pdks", "targets", "tools",
|
|
580
|
+
"references"]
|
|
581
|
+
names = order + sorted(set(top_tables) - set(order))
|
|
582
|
+
for key in names:
|
|
583
|
+
if key not in top_tables:
|
|
584
|
+
continue
|
|
585
|
+
if not first:
|
|
586
|
+
lines.append("")
|
|
587
|
+
first = False
|
|
588
|
+
value = top_tables[key]
|
|
589
|
+
if isinstance(value, dict):
|
|
590
|
+
lines.append(f"[{key}]")
|
|
591
|
+
emit_table(value, f"{key}.", key)
|
|
592
|
+
elif isinstance(value, list) and value and all(
|
|
593
|
+
isinstance(item, dict) for item in value
|
|
594
|
+
):
|
|
595
|
+
for item in value:
|
|
596
|
+
lines.append(f"[[{key}]]")
|
|
597
|
+
emit_table(item, f"{key}.", key)
|
|
598
|
+
else:
|
|
599
|
+
items = ", ".join(
|
|
600
|
+
emit_scalar(item, key) for item in value
|
|
601
|
+
)
|
|
602
|
+
lines.append(f"{key} = [{items}]")
|
|
603
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Per-entity compare-and-swap reads and writes.
|
|
2
|
+
|
|
3
|
+
Thin helpers over the ProjectService entity verbs: reads report
|
|
4
|
+
the live per-entity revision and writes gate on it, so editors
|
|
5
|
+
fail loudly with revision_conflict instead of merging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
_MISSING = object()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_entity_revision(service, entity):
|
|
12
|
+
"""Return the live per-entity revision of one entity.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
service: ProjectService handle to read through.
|
|
16
|
+
entity: Engine-minted entity id to inspect.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Integer entity revision (legacy records read as 1).
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
Exception: Whatever the service raises for unknown or
|
|
23
|
+
malformed entity ids.
|
|
24
|
+
"""
|
|
25
|
+
names = service.components_of(entity)["components"]
|
|
26
|
+
if not names:
|
|
27
|
+
return 1
|
|
28
|
+
got = service.get_component(entity, names[0])
|
|
29
|
+
return got.get("entity_revision", 1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def require_revision(service, entity, expected):
|
|
33
|
+
"""Check one entity revision without writing.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
service: ProjectService handle to read through.
|
|
37
|
+
entity: Engine-minted entity id to inspect.
|
|
38
|
+
expected: Revision the writer read, or None to skip.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Live entity revision the write may build on.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
Exception: revision_conflict when the live revision
|
|
45
|
+
differs from the expected one.
|
|
46
|
+
"""
|
|
47
|
+
live = read_entity_revision(service, entity)
|
|
48
|
+
if expected is not None and expected != live:
|
|
49
|
+
error = ValueError(
|
|
50
|
+
f"Entity {entity} at revision {live}, "
|
|
51
|
+
f"writer expected {expected}.",
|
|
52
|
+
)
|
|
53
|
+
error.code = "revision_conflict"
|
|
54
|
+
raise error
|
|
55
|
+
return live
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cas_set(service, entity, component, data, request_id,
|
|
59
|
+
command_text, expected_entity_revision,
|
|
60
|
+
expected_revision=None):
|
|
61
|
+
"""Upsert one component gated on its entity revision.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
service: ProjectService handle to write through.
|
|
65
|
+
entity: Entity id holding the component.
|
|
66
|
+
component: Component name to write.
|
|
67
|
+
data: JSON payload to store.
|
|
68
|
+
request_id: Client envelope id for idempotent replay.
|
|
69
|
+
command_text: Command text for identity comparison.
|
|
70
|
+
expected_entity_revision: Entity revision the writer
|
|
71
|
+
read; a stale value fails instead of merging.
|
|
72
|
+
expected_revision: Global head gate, skipped when an
|
|
73
|
+
entity revision is named (disjoint edits land).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Service mutation mapping with the bumped
|
|
77
|
+
entity_revision.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
Exception: revision_conflict for a stale entity
|
|
81
|
+
revision, or whatever the service raises.
|
|
82
|
+
"""
|
|
83
|
+
return service.set_component(
|
|
84
|
+
entity, component, data, request_id, command_text,
|
|
85
|
+
expected_revision=expected_revision,
|
|
86
|
+
expected_entity_revision=expected_entity_revision,
|
|
87
|
+
)
|