graphddb-runtime 0.7.8__tar.gz → 0.7.10__tar.gz
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.
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/PKG-INFO +2 -1
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/per_key_cursor.py +18 -6
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/runtime.py +45 -13
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/templates.py +58 -8
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/transactions.py +155 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime.egg-info/PKG-INFO +2 -1
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime.egg-info/SOURCES.txt +2 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime.egg-info/requires.txt +1 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/pyproject.toml +7 -2
- graphddb_runtime-0.7.10/tests/test_guard_eval.py +146 -0
- graphddb_runtime-0.7.10/tests/test_integration_binding_contract.py +425 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_spec_version_guard.py +64 -6
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/README.md +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/middleware.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/setup.cfg +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_crosslang_python_cells.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_description.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_description_followups.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_description_jsdoc.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_maintain.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_middleware.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_maintain.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_middleware.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_relations.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_ttl.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.10}/tests/test_unit.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphddb-runtime
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.10
|
|
4
4
|
Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.9
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: boto3>=1.26
|
|
9
|
+
Requires-Dist: behavior-contracts==0.1.3
|
|
9
10
|
Provides-Extra: test
|
|
10
11
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
11
12
|
|
|
@@ -29,9 +29,10 @@ envelope shape does not branch on arity).
|
|
|
29
29
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
|
|
32
|
-
import json
|
|
33
32
|
from typing import Any, Mapping, Optional
|
|
34
33
|
|
|
34
|
+
from behavior_contracts import canonical_value as _canonical_value
|
|
35
|
+
|
|
35
36
|
from .cursor import decode_cursor, encode_cursor
|
|
36
37
|
|
|
37
38
|
|
|
@@ -39,12 +40,23 @@ def serialize_contract_key(key: Mapping[str, Any]) -> str:
|
|
|
39
40
|
"""Canonical, cross-runtime-stable string identity of a contract key.
|
|
40
41
|
|
|
41
42
|
Object fields are sorted by name so ``{a, b}`` and ``{b, a}`` serialize
|
|
42
|
-
identically; values are emitted
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
identically; values are emitted as compact JSON. Matches the TS
|
|
44
|
+
``serializeContractKey`` (``JSON.stringify`` over a field-sorted object),
|
|
45
|
+
e.g. ``{"categoryId":"tech"}``.
|
|
46
|
+
|
|
47
|
+
**WS4 adapter (behavior-contracts)**: this is the shared COMMON
|
|
48
|
+
``canonicalValue`` primitive (runtime-boundary.md §2.1 →
|
|
49
|
+
canonical-serialization.md §2: key identity = top-level key sort + compact
|
|
50
|
+
JSON). graphddb now delegates to the extracted shared library instead of
|
|
51
|
+
re-implementing the field-sorted ``json.dumps``. Verified byte-for-byte
|
|
52
|
+
identical for every value shape a contract key carries (string / int / bool /
|
|
53
|
+
null / non-ASCII / nested-dict-insertion-order / array); both the shared and
|
|
54
|
+
the former graphddb impl reject non-JSON-scalar leaves such as ``Decimal``,
|
|
55
|
+
so the boundary is exact.
|
|
45
56
|
"""
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
# ``canonical_value`` performs the top-level key sort + compact JSON itself,
|
|
58
|
+
# so pass the key mapping (as a plain dict) straight through.
|
|
59
|
+
return _canonical_value(dict(key))
|
|
48
60
|
|
|
49
61
|
|
|
50
62
|
def encode_per_key_cursor(
|
|
@@ -19,6 +19,9 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
|
19
19
|
|
|
20
20
|
from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
|
|
21
21
|
|
|
22
|
+
from behavior_contracts import EnvelopeFailure as _EnvelopeFailure
|
|
23
|
+
from behavior_contracts import validate_envelope as _validate_envelope
|
|
24
|
+
|
|
22
25
|
from .batch import BatchGetExecutor, BatchWriteExecutor, serialize_key
|
|
23
26
|
from .concurrency import RELATION_TRAVERSAL_CONCURRENCY, map_with_concurrency
|
|
24
27
|
from .cursor import decode_cursor, encode_cursor
|
|
@@ -54,13 +57,21 @@ from .templates import (
|
|
|
54
57
|
validate_params,
|
|
55
58
|
)
|
|
56
59
|
from .transactions import MAX_TRANSACT_ITEMS, TransactionExpander
|
|
60
|
+
from .transactions import _guard_holds as _tx_guard_holds
|
|
57
61
|
from .transactions import _when_holds as _tx_when_holds
|
|
58
62
|
|
|
59
63
|
#: The operation-IR (spec) version this runtime implements — the newest
|
|
60
64
|
#: ``manifest.json`` / ``operations.json`` document version whose vocabulary it
|
|
61
65
|
#: fully understands. See :func:`_validate_spec_version` for the compatibility
|
|
62
66
|
#: rule.
|
|
63
|
-
|
|
67
|
+
#:
|
|
68
|
+
#: Phase 3 S6 (#266): bumped 1.1 -> 1.2 — the S6 half of the "one release column"
|
|
69
|
+
#: 1.2 bump (TS reached 1.2 in S5). This runtime now EVALUATES the 1.2 SCP
|
|
70
|
+
#: Expression IR ``guard`` vocabulary (see :func:`transactions._guard_holds`), so a
|
|
71
|
+
#: guard-bearing 1.2 bundle is ACCEPTED and executed on py/rust/go (matching TS).
|
|
72
|
+
#: Guard-free bundles remain stamped 1.1 by the generator (the conditional stamper
|
|
73
|
+
#: is unchanged), so an older runtime still runs them.
|
|
74
|
+
SPEC_VERSION_SUPPORTED = "1.2"
|
|
64
75
|
|
|
65
76
|
|
|
66
77
|
def _validate_spec_version(document: Mapping[str, Any], label: str) -> None:
|
|
@@ -83,26 +94,40 @@ def _validate_spec_version(document: Mapping[str, Any], label: str) -> None:
|
|
|
83
94
|
(forward-rejected);
|
|
84
95
|
- a missing / malformed version is rejected (an unversioned document
|
|
85
96
|
cannot be trusted to match any vocabulary).
|
|
97
|
+
|
|
98
|
+
**WS4 adapter (behavior-contracts)**: the ``<major>.<minor>`` fail-closed
|
|
99
|
+
comparison mechanism is the shared COMMON ``validate_envelope`` primitive
|
|
100
|
+
(runtime-boundary.md §2.1 — ``validateEnvelope``: DynamoDB-agnostic version
|
|
101
|
+
check). graphddb now delegates the parse + major-equal / minor-<= rule to the
|
|
102
|
+
shared library (which reads the ``version`` field here) and re-raises the
|
|
103
|
+
shared ``EnvelopeFailure`` as graphddb's public ``GraphDDBError`` with the
|
|
104
|
+
same actionable wording (test contract preserved).
|
|
86
105
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
try:
|
|
107
|
+
_validate_envelope(
|
|
108
|
+
document,
|
|
109
|
+
SPEC_VERSION_SUPPORTED,
|
|
110
|
+
field="version",
|
|
111
|
+
label=label,
|
|
112
|
+
)
|
|
113
|
+
except _EnvelopeFailure as exc:
|
|
114
|
+
version = document.get("version") if isinstance(document, Mapping) else None
|
|
115
|
+
if exc.code in ("MISSING_VERSION", "MALFORMED_VERSION"):
|
|
116
|
+
raise GraphDDBError(
|
|
117
|
+
f"{label}: missing or malformed spec version {version!r} — expected "
|
|
118
|
+
f"'<major>.<minor>' (this runtime supports {SPEC_VERSION_SUPPORTED}). "
|
|
119
|
+
f"Regenerate the document with a matching graphddb generator."
|
|
120
|
+
) from exc
|
|
121
|
+
supported_major, supported_minor = (
|
|
122
|
+
int(part) for part in SPEC_VERSION_SUPPORTED.split(".")
|
|
93
123
|
)
|
|
94
|
-
major, minor = (int(part) for part in version.split("."))
|
|
95
|
-
supported_major, supported_minor = (
|
|
96
|
-
int(part) for part in SPEC_VERSION_SUPPORTED.split(".")
|
|
97
|
-
)
|
|
98
|
-
if major != supported_major or minor > supported_minor:
|
|
99
124
|
raise GraphDDBError(
|
|
100
125
|
f"{label}: document spec version '{version}' is not supported by this "
|
|
101
126
|
f"runtime (supports major {supported_major}, minor <= {supported_minor}). "
|
|
102
127
|
f"A newer document may carry binding forms this runtime would silently "
|
|
103
128
|
f"mis-execute; upgrade graphddb_runtime or regenerate the document with "
|
|
104
129
|
f"a matching generator version."
|
|
105
|
-
)
|
|
130
|
+
) from exc
|
|
106
131
|
|
|
107
132
|
try: # pragma: no cover - exercised indirectly
|
|
108
133
|
from botocore.exceptions import BotoCoreError, ClientError
|
|
@@ -1392,6 +1417,9 @@ class GraphDDBRuntime:
|
|
|
1392
1417
|
continue # ConditionCheck — not a logical write op.
|
|
1393
1418
|
for_each = item.get("forEach")
|
|
1394
1419
|
when = item.get("when")
|
|
1420
|
+
# Issue #266 (Phase 3 S6): a guard-false item is skipped here too, so
|
|
1421
|
+
# W1 does not fire for a write the expander drops (parity with `when`).
|
|
1422
|
+
guard = item.get("guard")
|
|
1395
1423
|
if for_each:
|
|
1396
1424
|
source = params.get(for_each["source"])
|
|
1397
1425
|
if not isinstance(source, list):
|
|
@@ -1399,6 +1427,8 @@ class GraphDDBRuntime:
|
|
|
1399
1427
|
for element in source:
|
|
1400
1428
|
if when and not _tx_when_holds(when, params, element):
|
|
1401
1429
|
continue
|
|
1430
|
+
if guard is not None and not _tx_guard_holds(guard, params, element):
|
|
1431
|
+
continue
|
|
1402
1432
|
op_params = dict(params)
|
|
1403
1433
|
op_params.update({f"item.{k}": v for k, v in element.items()})
|
|
1404
1434
|
op_params.update(element)
|
|
@@ -1406,6 +1436,8 @@ class GraphDDBRuntime:
|
|
|
1406
1436
|
else:
|
|
1407
1437
|
if when and not _tx_when_holds(when, params, None):
|
|
1408
1438
|
continue
|
|
1439
|
+
if guard is not None and not _tx_guard_holds(guard, params, None):
|
|
1440
|
+
continue
|
|
1409
1441
|
yield kind, item, dict(params)
|
|
1410
1442
|
|
|
1411
1443
|
# ── command-contract execution (issue #64) ──────────────────────────────
|
|
@@ -16,12 +16,31 @@ from __future__ import annotations
|
|
|
16
16
|
import re
|
|
17
17
|
from typing import Any, Mapping
|
|
18
18
|
|
|
19
|
+
from behavior_contracts import TemplateFailure as _TemplateFailure
|
|
20
|
+
from behavior_contracts import render_template as _render_template
|
|
21
|
+
|
|
19
22
|
from .errors import ParameterValidationError
|
|
20
23
|
|
|
21
24
|
# A placeholder is `{` + name (no dot) + `}`. `{result.*}` is matched but flagged
|
|
22
25
|
# separately by the executor so it can raise the multi-op error.
|
|
23
26
|
_PLACEHOLDER = re.compile(r"\{([^{}]+)\}")
|
|
24
27
|
|
|
28
|
+
# Names resolved by a *different* layer than the core `{param}` primitive:
|
|
29
|
+
# `{result.*}` (relation-chain binding, resolved by the runPlan/relation layer)
|
|
30
|
+
# and `{item.*}` (Map/forEach expansion). See runtime-boundary.md §4.1: the
|
|
31
|
+
# shared `render_template` deliberately rejects these scopes (RESULT_SCOPE_IN_CORE)
|
|
32
|
+
# because they belong to graphddb's relation execution, not to the DSL-agnostic
|
|
33
|
+
# core. graphddb's `resolve_template` is overloaded to serve BOTH `{param}` key
|
|
34
|
+
# rendering AND `{result.*}` binding, so the adapter only delegates the former.
|
|
35
|
+
_NON_CORE_SCOPES = ("result.", "item.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_non_core_scope(template: str) -> bool:
|
|
39
|
+
return any(
|
|
40
|
+
m.group(1).startswith(_NON_CORE_SCOPES)
|
|
41
|
+
for m in _PLACEHOLDER.finditer(template)
|
|
42
|
+
)
|
|
43
|
+
|
|
25
44
|
|
|
26
45
|
def validate_params(
|
|
27
46
|
params: Mapping[str, Any],
|
|
@@ -118,14 +137,45 @@ def resolve_template(template: str, params: Mapping[str, Any]) -> str:
|
|
|
118
137
|
the single-table layout). Missing params should have been caught by
|
|
119
138
|
:func:`validate_params`; a stray placeholder here raises
|
|
120
139
|
:class:`ParameterValidationError`.
|
|
140
|
+
|
|
141
|
+
**WS4 adapter (behavior-contracts)** — this function is *overloaded* in
|
|
142
|
+
graphddb: it renders both the DSL-agnostic ``{param}`` primitive AND the
|
|
143
|
+
graphddb-specific ``{result.*}`` relation-chain binding (the relation layer
|
|
144
|
+
passes ``{"result.<field>": value}`` params with ``{result.<field>}``
|
|
145
|
+
templates, see ``runtime.py`` ``_relation_key_marker`` / belongsTo fan-out).
|
|
146
|
+
Per runtime-boundary.md §4.1 only the ``{param}`` case is COMMON, so:
|
|
147
|
+
|
|
148
|
+
- **pure ``{param}`` templates** → delegate to the shared COMMON
|
|
149
|
+
``render_template`` (single source of truth for the `{param}` grammar,
|
|
150
|
+
strict missing-key, and Python-parity stringify). The shared
|
|
151
|
+
``TemplateFailure(UNBOUND_PARAM)`` is translated to graphddb's
|
|
152
|
+
``ParameterValidationError`` to preserve graphddb's public error contract.
|
|
153
|
+
- **templates carrying ``{result.*}`` / ``{item.*}``** → keep graphddb's own
|
|
154
|
+
substitution (the shared core deliberately rejects these scopes; they are
|
|
155
|
+
resolved by graphddb's relation execution layer, not by the DSL core).
|
|
156
|
+
|
|
157
|
+
This is the WS4 boundary correction: graphddb's ``resolve_template`` does
|
|
158
|
+
*more* than the shared ``render_template``, so it cannot be swapped wholesale
|
|
159
|
+
— the shared core is wired in for the primitive it genuinely owns.
|
|
121
160
|
"""
|
|
161
|
+
if _has_non_core_scope(template):
|
|
162
|
+
# CONSUMER-SPECIFIC relation-layer binding: substitute every `{name}`
|
|
163
|
+
# (including `{result.*}` / `{item.*}`) with its param value. This is the
|
|
164
|
+
# part the shared core does not own (runtime-boundary.md §4.1).
|
|
165
|
+
def repl(match: "re.Match[str]") -> str:
|
|
166
|
+
name = match.group(1)
|
|
167
|
+
if name not in params or params[name] is None:
|
|
168
|
+
raise ParameterValidationError(
|
|
169
|
+
f"template '{template}' references unbound parameter '{name}'"
|
|
170
|
+
)
|
|
171
|
+
return str(params[name])
|
|
122
172
|
|
|
123
|
-
|
|
124
|
-
name = match.group(1)
|
|
125
|
-
if name not in params or params[name] is None:
|
|
126
|
-
raise ParameterValidationError(
|
|
127
|
-
f"template '{template}' references unbound parameter '{name}'"
|
|
128
|
-
)
|
|
129
|
-
return str(params[name])
|
|
173
|
+
return _PLACEHOLDER.sub(repl, template)
|
|
130
174
|
|
|
131
|
-
|
|
175
|
+
# COMMON `{param}` primitive → shared behavior-contracts.
|
|
176
|
+
try:
|
|
177
|
+
return _render_template(template, params)
|
|
178
|
+
except _TemplateFailure as exc:
|
|
179
|
+
# Preserve graphddb's public error contract (ParameterValidationError)
|
|
180
|
+
# while routing the mechanism through the shared library.
|
|
181
|
+
raise ParameterValidationError(str(exc)) from exc
|
|
@@ -18,6 +18,9 @@ from __future__ import annotations
|
|
|
18
18
|
import re
|
|
19
19
|
from typing import Any, Dict, List, Mapping, Optional
|
|
20
20
|
|
|
21
|
+
from behavior_contracts import ExprFailure as _ExprFailure
|
|
22
|
+
from behavior_contracts import evaluate_expression as _evaluate_expression
|
|
23
|
+
|
|
21
24
|
from .filters import compile_filter
|
|
22
25
|
|
|
23
26
|
_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
|
|
@@ -168,6 +171,134 @@ def _when_holds(
|
|
|
168
171
|
return left == right if when["op"] == "eq" else left != right
|
|
169
172
|
|
|
170
173
|
|
|
174
|
+
# ── SCP guard evaluation seam (issue #266, Phase 3 S6) ────────────────────────
|
|
175
|
+
#
|
|
176
|
+
# The branch-for-branch port of the TS ``evaluateGuard`` seam
|
|
177
|
+
# (``src/runtime/guard-eval.ts``, S5 / #265). A ``guard`` on a transaction item is
|
|
178
|
+
# an SCP Expression IR node — a boolean predicate over the transaction ``input``
|
|
179
|
+
# surface and, inside a ``forEach``, the current ``item`` element. S1 loud-rejected
|
|
180
|
+
# every guard-bearing bundle; S6 turns that reject into EVALUATION for the
|
|
181
|
+
# client-side ``input`` / ``item`` class, delegating the normative semantics to the
|
|
182
|
+
# shared ``behavior_contracts`` evaluator (``evaluate_expression``) — the SAME SSoT
|
|
183
|
+
# the TS/Rust/Go runtimes evaluate against, so a guard decides identically in every
|
|
184
|
+
# language: typed ``eq`` (int != string), i64 checked arithmetic, code-point string
|
|
185
|
+
# ordering, truncated-division ``mod`` sign.
|
|
186
|
+
#
|
|
187
|
+
# The two-condition-class rule (identical to TS): a ``guard`` references ONLY
|
|
188
|
+
# ``input`` / ``item`` (known client-side before the write), so guard-false skips
|
|
189
|
+
# the item — the same semantics the legacy ``when`` string-compare guard has, no
|
|
190
|
+
# DynamoDB round-trip. An expression that reads a DynamoDB attribute of the row
|
|
191
|
+
# being written is the ``scpExpr`` write-condition class (out of S6 scope) and must
|
|
192
|
+
# lower to a ``ConditionExpression``; :func:`_assert_guard_scopes` fail-closes a
|
|
193
|
+
# guard whose refs escape ``input`` / ``item`` so a client-side stale check can
|
|
194
|
+
# never happen.
|
|
195
|
+
|
|
196
|
+
_GUARD_SCOPE_ROOTS = frozenset(("input", "item"))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _assert_guard_scopes(node: Any, context: str) -> None:
|
|
200
|
+
"""Walk an expression IR node; reject any ``ref`` / ``refOpt`` whose ROOT segment
|
|
201
|
+
is not a client-side guard scope (``input`` / ``item``). Mirrors the TS
|
|
202
|
+
``assertGuardScopes`` — a ``prop``-rooted guard is the write-condition class that
|
|
203
|
+
must lower to a ``ConditionExpression``; evaluating it client-side would be a
|
|
204
|
+
stale read, so it is loud-rejected (issue #266)."""
|
|
205
|
+
if not isinstance(node, Mapping):
|
|
206
|
+
return
|
|
207
|
+
ref = node.get("ref")
|
|
208
|
+
if ref is None:
|
|
209
|
+
ref = node.get("refOpt")
|
|
210
|
+
if isinstance(ref, list):
|
|
211
|
+
root = ref[0] if ref else None
|
|
212
|
+
if isinstance(root, str) and root not in _GUARD_SCOPE_ROOTS:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"{context}: this guard references '{root}', but a client-side guard "
|
|
215
|
+
"may only reference the transaction 'input' / fan-out 'item' scopes. "
|
|
216
|
+
"An expression that reads a DynamoDB attribute must lower to a "
|
|
217
|
+
"ConditionExpression (write-condition class), not be evaluated "
|
|
218
|
+
"client-side (issue #266). Refusing to evaluate a stale check."
|
|
219
|
+
)
|
|
220
|
+
return
|
|
221
|
+
# Recurse every nested node (operator args, obj values, arr elements).
|
|
222
|
+
for value in node.values():
|
|
223
|
+
if isinstance(value, list):
|
|
224
|
+
for child in value:
|
|
225
|
+
_assert_guard_scopes(child, context)
|
|
226
|
+
elif isinstance(value, Mapping):
|
|
227
|
+
# ``{obj: {...}}`` construction — its values are nested nodes.
|
|
228
|
+
for child in value.values():
|
|
229
|
+
_assert_guard_scopes(child, context)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _to_shared_value(v: Any) -> Any:
|
|
233
|
+
"""Convert a plain graphddb runtime value into the ``behavior_contracts`` value
|
|
234
|
+
model, classifying by the VALUE's integrality — NOT by its declared numeric
|
|
235
|
+
subtype. This is the branch-for-branch mirror of the TS ``toSharedValue``
|
|
236
|
+
(#253): TS maps an integral JS ``number`` (``5`` == ``5.0``) to a ``bigint``
|
|
237
|
+
(int), so a scope value of ``5.0`` compares equal to an ``{int:"5"}`` literal.
|
|
238
|
+
|
|
239
|
+
graphddb's ``@number`` (R7) draws no int/float type distinction, so the only
|
|
240
|
+
cross-language-consistent rule is to classify by value integrality: an
|
|
241
|
+
integral-valued ``float`` (``5.0``) becomes a Python ``int`` (an int in the
|
|
242
|
+
shared model), a fractional ``float`` stays a ``float``. Without this, Python's
|
|
243
|
+
native ``isinstance(v, float)`` would classify ``5.0`` as float and an
|
|
244
|
+
``input.n == {int:"5"}`` guard would TYPE_MISMATCH where TS returns True —
|
|
245
|
+
a cross-language divergence (S5 audit rule, issue #266)."""
|
|
246
|
+
if isinstance(v, bool):
|
|
247
|
+
# bool is an int subclass in Python; keep it a bool in the shared model.
|
|
248
|
+
return v
|
|
249
|
+
if isinstance(v, float):
|
|
250
|
+
# Integral-valued float -> int (matches TS Number.isInteger). NaN/Inf are
|
|
251
|
+
# non-integral, so they stay float and the shared core loud-rejects them.
|
|
252
|
+
if v.is_integer():
|
|
253
|
+
return int(v)
|
|
254
|
+
return v
|
|
255
|
+
if isinstance(v, list):
|
|
256
|
+
return [_to_shared_value(e) for e in v]
|
|
257
|
+
if isinstance(v, Mapping):
|
|
258
|
+
return {k: _to_shared_value(field) for k, field in v.items()}
|
|
259
|
+
return v
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _to_shared_params(params: Mapping[str, Any]) -> Dict[str, Any]:
|
|
263
|
+
return {name: _to_shared_value(value) for name, value in params.items()}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _guard_holds(
|
|
267
|
+
guard: Mapping[str, Any],
|
|
268
|
+
params: Mapping[str, Any],
|
|
269
|
+
element: Optional[Mapping[str, Any]],
|
|
270
|
+
context: str = "declarative transaction guard",
|
|
271
|
+
) -> bool:
|
|
272
|
+
"""Evaluate a ``guard`` ExpressionSpec against the transaction ``input`` params
|
|
273
|
+
and the (optional) ``forEach`` element, returning its boolean truth value.
|
|
274
|
+
Delegates the normative semantics to the shared ``behavior_contracts`` evaluator
|
|
275
|
+
— the same SSoT every other-language runtime evaluates against (S5/S6/S7).
|
|
276
|
+
|
|
277
|
+
Raises ``ValueError`` if the guard escapes the ``input`` / ``item`` scopes
|
|
278
|
+
(:func:`_assert_guard_scopes`), if the shared evaluator reports a Failure (i64
|
|
279
|
+
overflow, type mismatch, unknown op, unknown exprVersion, …), or if the guard
|
|
280
|
+
does not evaluate to a boolean — all fail-closed / loud (issue #266)."""
|
|
281
|
+
expr = guard["expr"]
|
|
282
|
+
_assert_guard_scopes(expr, context)
|
|
283
|
+
scope: Dict[str, Any] = {"input": _to_shared_params(params)}
|
|
284
|
+
if element is not None:
|
|
285
|
+
scope["item"] = _to_shared_value(dict(element))
|
|
286
|
+
try:
|
|
287
|
+
result = _evaluate_expression(expr, scope)
|
|
288
|
+
except _ExprFailure as exc:
|
|
289
|
+
code = getattr(exc, "code", "?")
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"{context}: guard evaluation failed ({code}): {exc}"
|
|
292
|
+
) from exc
|
|
293
|
+
if not isinstance(result, bool):
|
|
294
|
+
got = "null" if result is None else type(result).__name__
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"{context}: a guard expression must evaluate to a boolean, got {got} "
|
|
297
|
+
"(issue #266)."
|
|
298
|
+
)
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
171
302
|
def _apply_maintain_transform(op: str, args: List[Any], value: Any) -> Any:
|
|
172
303
|
"""Apply one maintenance projection transform to a resolved source value (#129,
|
|
173
304
|
Epic #118) — the branch-for-branch mirror of the TS in-process
|
|
@@ -477,6 +608,19 @@ class TransactionExpander:
|
|
|
477
608
|
values[alias] = self._serializer.serialize(resolved)
|
|
478
609
|
target["ConditionExpression"] = condition["expression"]
|
|
479
610
|
return
|
|
611
|
+
if kind == "scpExpr":
|
|
612
|
+
# Issue #266 (Phase 3 S6): the ``scpExpr`` write-condition class refs a
|
|
613
|
+
# DynamoDB attribute of the row being written, so it must lower to a
|
|
614
|
+
# ConditionExpression — NOT be evaluated client-side (a stale read is a
|
|
615
|
+
# #242-type silent drop). That lowering is out of S6 scope, so fail
|
|
616
|
+
# loudly rather than execute the write with its condition ignored.
|
|
617
|
+
# (Mirrors the TS ``applyCondition`` scpExpr reject.)
|
|
618
|
+
raise ValueError(
|
|
619
|
+
"declarative transaction: this runtime does not evaluate scpExpr "
|
|
620
|
+
"write conditions yet (spec 1.2 expression vocabulary, issue #261); "
|
|
621
|
+
"the write-condition class must lower to a ConditionExpression. "
|
|
622
|
+
"Refusing to execute the write with its condition ignored."
|
|
623
|
+
)
|
|
480
624
|
clauses = []
|
|
481
625
|
for i, (field, tmpl) in enumerate(condition["fields"].items()):
|
|
482
626
|
n = f"#e{i}"
|
|
@@ -667,6 +811,13 @@ class TransactionExpander:
|
|
|
667
811
|
for item in spec.get("items", []):
|
|
668
812
|
for_each = item.get("forEach")
|
|
669
813
|
when = item.get("when")
|
|
814
|
+
# Issue #266 (Phase 3 S6): a `guard` (SCP Expression IR over the
|
|
815
|
+
# `input` / fan-out `item` scopes) is EVALUATED via behavior_contracts
|
|
816
|
+
# — the normative SSoT every runtime shares. Guard-false skips the item
|
|
817
|
+
# (matching the legacy string-compare `when`); guard-true keeps it. An
|
|
818
|
+
# attribute-referencing guard is loud-rejected inside `_guard_holds`
|
|
819
|
+
# (write-condition class, out of scope). Matches the TS S5 seam.
|
|
820
|
+
guard = item.get("guard")
|
|
670
821
|
if for_each:
|
|
671
822
|
source = params.get(for_each["source"])
|
|
672
823
|
if not isinstance(source, list):
|
|
@@ -677,9 +828,13 @@ class TransactionExpander:
|
|
|
677
828
|
for element in source:
|
|
678
829
|
if when and not _when_holds(when, params, element):
|
|
679
830
|
continue
|
|
831
|
+
if guard is not None and not _guard_holds(guard, params, element):
|
|
832
|
+
continue
|
|
680
833
|
expanded.append(self._build_item(item, params, element))
|
|
681
834
|
else:
|
|
682
835
|
if when and not _when_holds(when, params, None):
|
|
683
836
|
continue
|
|
837
|
+
if guard is not None and not _guard_holds(guard, params, None):
|
|
838
|
+
continue
|
|
684
839
|
expanded.append(self._build_item(item, params, None))
|
|
685
840
|
return _collapse_same_key_items(expanded)
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphddb-runtime
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.10
|
|
4
4
|
Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.9
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: boto3>=1.26
|
|
9
|
+
Requires-Dist: behavior-contracts==0.1.3
|
|
9
10
|
Provides-Extra: test
|
|
10
11
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
11
12
|
|
|
@@ -26,7 +26,9 @@ tests/test_crosslang_python_cells.py
|
|
|
26
26
|
tests/test_description.py
|
|
27
27
|
tests/test_description_followups.py
|
|
28
28
|
tests/test_description_jsdoc.py
|
|
29
|
+
tests/test_guard_eval.py
|
|
29
30
|
tests/test_integration.py
|
|
31
|
+
tests/test_integration_binding_contract.py
|
|
30
32
|
tests/test_integration_command.py
|
|
31
33
|
tests/test_integration_compose.py
|
|
32
34
|
tests/test_integration_contract.py
|
|
@@ -4,12 +4,17 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphddb-runtime"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.10"
|
|
8
8
|
description = "Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
|
-
|
|
12
|
+
# behavior-contracts (formerly dsl-contracts) epic (WS8): the COMMON runtime
|
|
13
|
+
# primitives (spec-version validation, {param} template rendering, contract-key
|
|
14
|
+
# canonicalisation) are delegated to the extracted shared package
|
|
15
|
+
# ``behavior-contracts`` (import name ``behavior_contracts``). The runtime
|
|
16
|
+
# imports it unconditionally, so it is a HARD dependency.
|
|
17
|
+
dependencies = ["boto3>=1.26", "behavior-contracts==0.1.3"]
|
|
13
18
|
|
|
14
19
|
[project.optional-dependencies]
|
|
15
20
|
test = ["pytest>=7.0"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""SCP guard evaluation tests (issue #266, Phase 3 S6).
|
|
2
|
+
|
|
3
|
+
The py runtime evaluates a transaction-item ``guard`` (an SCP Expression IR node
|
|
4
|
+
over the ``input`` / fan-out ``item`` scopes) via the shared ``behavior_contracts``
|
|
5
|
+
evaluator — the SAME normative SSoT the TS S5 seam (``src/runtime/guard-eval.ts``)
|
|
6
|
+
evaluates against, so a guard decides IDENTICALLY across TS/py/rust/go. These
|
|
7
|
+
tests pin the adversarial matrix the S5 audit covered: typed ``eq`` (int != string),
|
|
8
|
+
i64 boundary, code-point string compare, truncated ``mod`` sign, non-bool loud,
|
|
9
|
+
unknown-op loud, scope-escape loud, and — the critical cross-language rule — an
|
|
10
|
+
integral-valued number classifies as int (matching the TS #253 adapter), NOT as
|
|
11
|
+
float -> TYPE_MISMATCH.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from graphddb_runtime.transactions import _guard_holds, _to_shared_value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _holds(expr: Any, params: dict, element: Optional[dict] = None) -> bool:
|
|
24
|
+
return _guard_holds({"exprVersion": 1, "expr": expr}, params, element)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── the integral-float classification rule (the crux, S5 audit) ───────────────
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_integral_float_param_classifies_as_int():
|
|
31
|
+
# A param value of 5.0 must compare EQUAL to an {int:"5"} literal — matching
|
|
32
|
+
# the TS adapter (Number.isInteger(5.0) -> bigint). graphddb @number (R7) has
|
|
33
|
+
# no int/float distinction, so classification is by VALUE integrality.
|
|
34
|
+
assert _holds({"eq": [{"ref": ["input", "n"]}, {"int": "5"}]}, {"n": 5.0}) is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_integral_int_param_classifies_as_int():
|
|
38
|
+
assert _holds({"eq": [{"ref": ["input", "n"]}, {"int": "5"}]}, {"n": 5}) is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_fractional_float_param_mismatches_int_literal():
|
|
42
|
+
# 5.5 is genuinely a float; comparing it to an int literal is a TYPE_MISMATCH
|
|
43
|
+
# (int != float is normative) — matching TS.
|
|
44
|
+
with pytest.raises(ValueError, match="TYPE_MISMATCH"):
|
|
45
|
+
_holds({"eq": [{"ref": ["input", "n"]}, {"int": "5"}]}, {"n": 5.5})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_to_shared_value_classifies_by_integrality():
|
|
49
|
+
assert _to_shared_value(5.0) == 5 and isinstance(_to_shared_value(5.0), int)
|
|
50
|
+
assert isinstance(_to_shared_value(5.5), float)
|
|
51
|
+
# bool is an int subclass in Python but must stay a bool in the shared model.
|
|
52
|
+
assert _to_shared_value(True) is True
|
|
53
|
+
# nested integral floats reclassify recursively.
|
|
54
|
+
assert _to_shared_value({"a": 2.0, "b": [3.0, 4.5]}) == {"a": 2, "b": [3, 4.5]}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── typed eq (int != string) ──────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_typed_eq_int_never_equals_string():
|
|
61
|
+
with pytest.raises(ValueError, match="TYPE_MISMATCH"):
|
|
62
|
+
_holds({"eq": [{"int": "5"}, "5"]}, {})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_string_eq_string_by_value():
|
|
66
|
+
assert _holds({"eq": [{"ref": ["input", "role"]}, "admin"]}, {"role": "admin"}) is True
|
|
67
|
+
assert _holds({"eq": [{"ref": ["input", "role"]}, "admin"]}, {"role": "user"}) is False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── i64 boundary ──────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_i64_max_boundary_ok():
|
|
74
|
+
# 2^63-1 is the max i64; adding within range is fine.
|
|
75
|
+
assert _holds(
|
|
76
|
+
{"eq": [{"add": [{"int": "9223372036854775806"}, {"int": "1"}]}, {"int": "9223372036854775807"}]},
|
|
77
|
+
{},
|
|
78
|
+
) is True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_i64_overflow_is_loud():
|
|
82
|
+
with pytest.raises(ValueError, match="INT_OVERFLOW"):
|
|
83
|
+
_holds({"lt": [{"add": [{"int": "9223372036854775807"}, {"int": "1"}]}, {"int": "0"}]}, {})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── code-point string compare ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_string_compare_is_code_point_order():
|
|
90
|
+
# 'Z' (0x5A) < 'a' (0x61) in code-point order.
|
|
91
|
+
assert _holds({"lt": ["Z", "a"]}, {}) is True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── truncated mod sign (sign of dividend) ─────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_mod_sign_follows_dividend_negative():
|
|
98
|
+
# -7 mod 3 == -1 (truncated division; sign follows dividend, NOT Python %).
|
|
99
|
+
assert _holds({"eq": [{"mod": [{"int": "-7"}, {"int": "3"}]}, {"int": "-1"}]}, {}) is True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_mod_sign_follows_dividend_positive():
|
|
103
|
+
assert _holds({"eq": [{"mod": [{"int": "7"}, {"int": "-3"}]}, {"int": "1"}]}, {}) is True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── non-bool result is loud ───────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_non_bool_guard_is_loud():
|
|
110
|
+
with pytest.raises(ValueError, match="must evaluate to a boolean"):
|
|
111
|
+
_holds({"int": "5"}, {})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_null_guard_result_is_loud():
|
|
115
|
+
with pytest.raises(ValueError, match="must evaluate to a boolean"):
|
|
116
|
+
_holds({"refOpt": ["input", "missing"]}, {"missing": None})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── unknown op / unknown exprVersion is loud (fail-closed) ────────────────────
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_unknown_op_is_loud():
|
|
123
|
+
with pytest.raises(ValueError, match="UNKNOWN_OP"):
|
|
124
|
+
_holds({"frobnicate": [1, 2]}, {})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── scope escape (prop-rooted guard) is loud ──────────────────────────────────
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_prop_rooted_guard_is_rejected():
|
|
131
|
+
with pytest.raises(ValueError, match="only reference the transaction 'input'"):
|
|
132
|
+
_holds({"eq": [{"ref": ["prop", "status"]}, "closed"]}, {})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_nested_prop_ref_is_rejected():
|
|
136
|
+
with pytest.raises(ValueError, match="only reference the transaction 'input'"):
|
|
137
|
+
_holds({"and": [True, {"eq": [{"ref": ["prop", "x"]}, "y"]}]}, {})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── skip / keep semantics ─────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_guard_true_keeps_guard_false_skips():
|
|
144
|
+
expr = {"eq": [{"ref": ["input", "active"]}, True]}
|
|
145
|
+
assert _holds(expr, {"active": True}) is True
|
|
146
|
+
assert _holds(expr, {"active": False}) is False
|