behavior-contracts 0.1.2__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.
@@ -0,0 +1,284 @@
1
+ """conformance.py — dsl-contracts conformance kit の Python runner。
2
+
3
+ python -m behavior_contracts.conformance
4
+ (または console script: behavior-contracts-conformance)
5
+
6
+ conformance/vectors/*.json を読み、各 vector を本パッケージのプリミティブ
7
+ (evaluate_expression / render_template / run_plan / canonical)へ通し、expect と比較して
8
+ pass/fail を集計する。全 suite PASS で exit 0、1 件でも fail で exit 1。
9
+
10
+ これは conformance/PROTOCOL.md の Python 実装。TS 参照 runner
11
+ (examples/src/conformance-run.ts)と同じ JSON を読み、同じ判定規則で同じ pass/fail を出す。
12
+
13
+ spec version fail-closed(§5): 全 suite JSON の版を **pre-flight(実行前一括)** で照合し、
14
+ 1 つでも不一致なら vector を 1 件も実行せず exit 2 で loud reject する。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from . import (
26
+ SPEC_VERSIONS,
27
+ CanonicalFailure,
28
+ ExprFailure,
29
+ PlanFailure,
30
+ TemplateFailure,
31
+ canonical_json,
32
+ canonical_value,
33
+ decode_value,
34
+ deep_equals,
35
+ encode_value,
36
+ evaluate_expression,
37
+ final_tree,
38
+ py_float_repr,
39
+ render_template,
40
+ run_plan,
41
+ )
42
+
43
+
44
+ def _vectors_dir() -> Path:
45
+ """conformance/vectors ディレクトリを探す。
46
+
47
+ 優先順: ``DSL_CONTRACTS_VECTORS`` 環境変数 → リポジトリ相対
48
+ (python/src/behavior_contracts/ から ../../../conformance/vectors)。
49
+ """
50
+ env = os.environ.get("DSL_CONTRACTS_VECTORS")
51
+ if env:
52
+ return Path(env)
53
+ here = Path(__file__).resolve()
54
+ # here = .../dsl-contracts/python/src/behavior_contracts/conformance.py
55
+ candidate = here.parents[3] / "conformance" / "vectors"
56
+ return candidate
57
+
58
+
59
+ def _load_json(vectors_dir: Path, file: str) -> Any:
60
+ with open(vectors_dir / file, "r", encoding="utf-8") as fh:
61
+ return json.load(fh)
62
+
63
+
64
+ class _Tally:
65
+ __slots__ = ("passed", "failed")
66
+
67
+ def __init__(self) -> None:
68
+ self.passed = 0
69
+ self.failed = 0
70
+
71
+
72
+ def _line(ok: bool, name: str, detail: str = "") -> None:
73
+ if ok:
74
+ print(f" ✓ {name}")
75
+ else:
76
+ print(f" ✗ {name}")
77
+ if detail:
78
+ print(f" {detail}")
79
+
80
+
81
+ # ── pre-flight version sweep(PROTOCOL.md §5)──────────────────────────────────
82
+ def _preflight(vectors_dir: Path) -> Dict[str, Any]:
83
+ specs = [
84
+ ("expression.json", "expression", "exprVersion", SPEC_VERSIONS["expression"]),
85
+ ("template.json", "template", "templateVersion", SPEC_VERSIONS["template"]),
86
+ ("plan.json", "plan", "planVersion", SPEC_VERSIONS["plan"]),
87
+ ("canonical.json", "canonical", "canonicalVersion", SPEC_VERSIONS["canonical"]),
88
+ ]
89
+ loaded = [(f, suite, vk, want, _load_json(vectors_dir, f)) for (f, suite, vk, want) in specs]
90
+ mismatches = [(suite, doc.get(vk), want) for (_, suite, vk, want, doc) in loaded if doc.get(vk) != want]
91
+ if mismatches:
92
+ for suite, got, want in mismatches:
93
+ print(
94
+ f"FAIL-CLOSED: {suite} suite version {got} != supported {want}.",
95
+ file=sys.stderr,
96
+ )
97
+ print(
98
+ f"Refusing to run: {len(mismatches)} suite version mismatch(es). "
99
+ f"No vectors executed.",
100
+ file=sys.stderr,
101
+ )
102
+ sys.exit(2)
103
+ return {suite: doc for (_, suite, _vk, _want, doc) in loaded}
104
+
105
+
106
+ # ── expression suite ──────────────────────────────────────────────────────────
107
+ def _run_expression(t: _Tally, doc: Any) -> None:
108
+ print(f"\nexpression.json (v{doc['exprVersion']}) — {len(doc['vectors'])} vectors")
109
+ for v in doc["vectors"]:
110
+ ok = False
111
+ detail = ""
112
+ try:
113
+ scope = decode_value(v["scope"]) if v.get("scope") is not None else {}
114
+ result = evaluate_expression(v["expr"], scope)
115
+ if "value" in v["expect"]:
116
+ ok = deep_equals(result, decode_value(v["expect"]["value"]))
117
+ if not ok:
118
+ detail = (
119
+ f"expected value {json.dumps(v['expect']['value'])}, "
120
+ f"got {json.dumps(encode_value(result))}"
121
+ )
122
+ else:
123
+ detail = f"expected Failure({v['expect']['failure']}), got a value"
124
+ except ExprFailure as e:
125
+ if "failure" in v["expect"]:
126
+ ok = e.code == v["expect"]["failure"]
127
+ if not ok:
128
+ detail = f"expected Failure({v['expect']['failure']}), got Failure({e.code})"
129
+ else:
130
+ detail = f"expected a value, got Failure({e.code})"
131
+ _line(ok, v["name"], detail)
132
+ _bump(t, ok)
133
+
134
+
135
+ # ── template suite ────────────────────────────────────────────────────────────
136
+ def _run_template(t: _Tally, doc: Any) -> None:
137
+ print(f"\ntemplate.json (v{doc['templateVersion']}) — {len(doc['vectors'])} vectors")
138
+ for v in doc["vectors"]:
139
+ ok = False
140
+ detail = ""
141
+ params = {k: decode_value(val) for k, val in v["params"].items()}
142
+ try:
143
+ out = render_template(v["template"], params)
144
+ if "ok" in v["expect"]:
145
+ ok = out == v["expect"]["ok"]
146
+ if not ok:
147
+ detail = f"expected {v['expect']['ok']!r}, got {out!r}"
148
+ else:
149
+ detail = f"expected Failure({v['expect']['failure']}), got {out!r}"
150
+ except TemplateFailure as e:
151
+ if "failure" in v["expect"]:
152
+ ok = e.code == v["expect"]["failure"]
153
+ if not ok:
154
+ detail = f"expected Failure({v['expect']['failure']}), got Failure({e.code})"
155
+ else:
156
+ detail = f"expected {v['expect']['ok']!r}, got Failure({e.code})"
157
+ _line(ok, v["name"], detail)
158
+ _bump(t, ok)
159
+
160
+
161
+ # ── canonical suite ───────────────────────────────────────────────────────────
162
+ def _run_canonical(t: _Tally, doc: Any) -> None:
163
+ print(f"\ncanonical.json (v{doc['canonicalVersion']}) — {len(doc['vectors'])} vectors")
164
+ for v in doc["vectors"]:
165
+ ok = False
166
+ detail = ""
167
+ try:
168
+ val = decode_value(v["value"])
169
+ kind = v["kind"]
170
+ if kind == "canonicalValue":
171
+ out = canonical_value(val)
172
+ elif kind == "canonicalJson":
173
+ out = canonical_json(val)
174
+ elif kind == "floatRepr":
175
+ if not isinstance(val, float):
176
+ raise ValueError(f"floatRepr expects a float, got {type(val).__name__}")
177
+ out = py_float_repr(val)
178
+ else:
179
+ raise ValueError(f"unknown kind: {kind}")
180
+ if "ok" in v["expect"]:
181
+ ok = out == v["expect"]["ok"]
182
+ if not ok:
183
+ detail = f"expected {v['expect']['ok']!r}, got {out!r}"
184
+ else:
185
+ detail = f"expected Failure({v['expect']['failure']}), got {out!r}"
186
+ except CanonicalFailure as e:
187
+ if "failure" in v["expect"]:
188
+ ok = e.code == v["expect"]["failure"]
189
+ if not ok:
190
+ detail = f"expected Failure({v['expect']['failure']}), got Failure({e.code})"
191
+ else:
192
+ detail = f"expected {v['expect']['ok']!r}, got Failure({e.code})"
193
+ _line(ok, v["name"], detail)
194
+ _bump(t, ok)
195
+
196
+
197
+ # ── plan suite ────────────────────────────────────────────────────────────────
198
+ def _run_plan(t: _Tally, doc: Any) -> None:
199
+ print(f"\nplan.json (v{doc['planVersion']}) — {len(doc['vectors'])} vectors")
200
+ for v in doc["vectors"]:
201
+ ok = False
202
+ detail = ""
203
+ plan = v.get("plan")
204
+ ops = v["ops"]
205
+ exec_map = v["exec"]
206
+
207
+ def make_exec(exec_map: Dict[str, Any]):
208
+ def exec_fn(op: Any, bound_value: Any) -> Dict[str, Any]:
209
+ o = exec_map.get(op["id"])
210
+ if o is None:
211
+ raise ValueError(f"no mock outcome for op '{op['id']}'")
212
+ if "ok" in o:
213
+ return {"ok": decode_value(o["ok"])}
214
+ return {"error": o["error"]}
215
+ return exec_fn
216
+
217
+ try:
218
+ res = run_plan(plan, ops, make_exec(exec_map))
219
+ if "failure" in v["expect"]:
220
+ detail = f"expected Failure({v['expect']['failure']}), got a full run"
221
+ else:
222
+ tree = final_tree(res["states"], ops)
223
+ exp_tree = {k: decode_value(val) for k, val in v["expect"]["tree"].items()}
224
+ tree_ok = canonical_json(tree) == canonical_json(exp_tree)
225
+ exec_ok = _same_set(res["executed"], v["expect"]["executed"])
226
+ skip_ok = _same_set(res["skipped"], v["expect"]["skipped"])
227
+ ok = tree_ok and exec_ok and skip_ok
228
+ if not ok:
229
+ parts: List[str] = []
230
+ if not tree_ok:
231
+ parts.append(f"tree {canonical_json(tree)} != {canonical_json(exp_tree)}")
232
+ if not exec_ok:
233
+ parts.append(f"executed {res['executed']} != {v['expect']['executed']}")
234
+ if not skip_ok:
235
+ parts.append(f"skipped {res['skipped']} != {v['expect']['skipped']}")
236
+ detail = "; ".join(parts)
237
+ except PlanFailure as e:
238
+ if "failure" in v["expect"]:
239
+ ok = e.code == v["expect"]["failure"]
240
+ if not ok:
241
+ detail = f"expected Failure({v['expect']['failure']}), got Failure({e.code})"
242
+ else:
243
+ detail = f"expected a full run, got Failure({e.code})"
244
+ _line(ok, v["name"], detail)
245
+ _bump(t, ok)
246
+
247
+
248
+ def _same_set(a: List[str], b: List[str]) -> bool:
249
+ return sorted(a) == sorted(b)
250
+
251
+
252
+ def _bump(t: _Tally, ok: bool) -> None:
253
+ if ok:
254
+ t.passed += 1
255
+ else:
256
+ t.failed += 1
257
+
258
+
259
+ def run(vectors_dir: Optional[Path] = None) -> int:
260
+ """conformance run を実行し、失敗数を返す(version mismatch は exit 2 で即終了)。"""
261
+ vd = vectors_dir or _vectors_dir()
262
+ print("dsl-contracts conformance kit — Python runner")
263
+
264
+ docs = _preflight(vd) # STEP 1: pre-flight(不一致は exit 2)
265
+
266
+ # STEP 2: 全版一致を確認したうえで実行。
267
+ t = _Tally()
268
+ _run_expression(t, docs["expression"])
269
+ _run_template(t, docs["template"])
270
+ _run_plan(t, docs["plan"])
271
+ _run_canonical(t, docs["canonical"])
272
+
273
+ total = t.passed + t.failed
274
+ print(f"\n{t.passed} passed, {t.failed} failed / {total} vectors across 4 suites")
275
+ return t.failed
276
+
277
+
278
+ def main() -> None:
279
+ failed = run()
280
+ sys.exit(1 if failed > 0 else 0)
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()
@@ -0,0 +1,89 @@
1
+ """envelope.py — validate_envelope(spec-version fail-closed 検査)。Python port。
2
+
3
+ TS 参照実装 ``ts/src/envelope.ts`` の意味論を完全一致で移植。graphddb の
4
+ ``_validate_spec_version``(``runtime.py``)を DSL 非依存に一般化したもの。DynamoDB 概念は
5
+ ゼロ — ``<major>.<minor>`` の比較機構のみ。
6
+
7
+ 互換規則(graphddb と同一・PROTOCOL.md §5 の fail-closed 規律):
8
+ - version 文字列は ``"<major>.<minor>"``(整数 2 つ)でなければならない。形式違反は fail-closed。
9
+ - major は supported major と一致しなければならない(major bump = 破壊的変更)。
10
+ - minor は supported minor 以下でなければならない(minor は additive)。
11
+ - 未知(範囲外)version は黙って通さず loud reject する。
12
+
13
+ ``supported`` は consumer が渡す(core は比較機構を提供するのみ)。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Any, Mapping, Optional
20
+
21
+ _ENVELOPE_FAILURE_CODES = frozenset(
22
+ {"MISSING_VERSION", "MALFORMED_VERSION", "UNSUPPORTED_MAJOR", "UNSUPPORTED_MINOR"}
23
+ )
24
+
25
+ _VERSION_RE = re.compile(r"^\d+$")
26
+
27
+
28
+ class EnvelopeFailure(Exception):
29
+ """envelope 検査の Failure。"""
30
+
31
+ def __init__(self, code: str, message: str) -> None:
32
+ super().__init__(message)
33
+ self.code = code
34
+
35
+
36
+ def _fail(code: str, message: str) -> "None":
37
+ raise EnvelopeFailure(code, message)
38
+
39
+
40
+ def _parse_version(version: str, label: str):
41
+ parts = version.split(".")
42
+ if len(parts) != 2:
43
+ _fail("MALFORMED_VERSION", f"{label}: version '{version}' must be '<major>.<minor>'")
44
+ maj_s, min_s = parts
45
+ if not _VERSION_RE.match(maj_s) or not _VERSION_RE.match(min_s):
46
+ _fail(
47
+ "MALFORMED_VERSION",
48
+ f"{label}: version '{version}' must be two integers '<major>.<minor>'",
49
+ )
50
+ return int(maj_s), int(min_s)
51
+
52
+
53
+ def validate_envelope(
54
+ envelope: Any,
55
+ supported: str,
56
+ *,
57
+ field: Optional[str] = None,
58
+ label: Optional[str] = None,
59
+ ) -> None:
60
+ """envelope の spec version を fail-closed で検査する。
61
+
62
+ :param envelope: ``specVersion`` を持つ任意の document(graphddb の manifest/operations 等)。
63
+ :param supported: この runtime が対応する版 ``"<major>.<minor>"``(consumer が宣言)。
64
+ :param field: version を読むフィールド名(既定 ``"specVersion"``)。
65
+ :param label: エラーメッセージ用ラベル(既定 ``"envelope"``)。
66
+ :raises EnvelopeFailure: 形式違反・未対応 major/minor のとき。
67
+ """
68
+ field = field or "specVersion"
69
+ label = label or "envelope"
70
+ if not isinstance(envelope, Mapping):
71
+ _fail("MISSING_VERSION", f"{label}: not an object")
72
+ raw = envelope.get(field)
73
+ if not isinstance(raw, str):
74
+ _fail("MISSING_VERSION", f"{label}: missing string '{field}'")
75
+
76
+ got_major, got_minor = _parse_version(raw, label)
77
+ want_major, want_minor = _parse_version(supported, "supported")
78
+
79
+ if got_major != want_major:
80
+ _fail(
81
+ "UNSUPPORTED_MAJOR",
82
+ f"{label}: version {raw} has major {got_major} but runtime supports "
83
+ f"major {want_major}",
84
+ )
85
+ if got_minor > want_minor:
86
+ _fail(
87
+ "UNSUPPORTED_MINOR",
88
+ f"{label}: version {raw} has minor {got_minor} > supported minor {want_minor}",
89
+ )