parsanol 0.1.0__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.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: parsanol
3
+ Version: 0.1.0
4
+ Summary: Python runtime for parsanol PARG artifacts: checksum-verified artifact load and wasm-grade parsing through the parsanol C ABI
5
+ License: MIT
6
+ Project-URL: Homepage, https://www.parsanol.org
7
+ Project-URL: Repository, https://github.com/parsanol/parsanol-python
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Software Development :: Compilers
10
+ Classifier: Topic :: Text Processing :: General
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # parsanol (Python)
15
+
16
+ Python runtime for **PARG artifacts** — checksummed `parsanol-tree/v2`
17
+ envelopes that Ruby, Rust, WebAssembly, TypeScript, and now Python all
18
+ parse identically.
19
+
20
+ The engine is the real one: this package drives `libparsanol` (the
21
+ pure-portable C ABI cdylib, the same binary the Ruby ffi tier ships) via
22
+ `ctypes`. No second parser, no reimplementation.
23
+
24
+ ```python
25
+ from parsanol import PargArtifact
26
+
27
+ artifact = PargArtifact.from_path("demo.artifact.json") # verifies sha256
28
+ tree = artifact.parse("iso 12345", "identifier") # shaped tree
29
+ run = artifact.run_tests() # embedded tests replayed
30
+ ```
31
+
32
+ On rejection, `parse` raises `ParseFailed` carrying the engine's failure
33
+ wire (`offset`, ranked positions, expected set).
34
+
35
+ ## Status and honest boundaries (v0.1)
36
+
37
+ | Capability | State |
38
+ |---|---|
39
+ | Artifact load + sha256 verification | **works** — the canonical-form port is byte-exact against the reference compiler (all 32 corpus checksums verify) |
40
+ | Native parse (shaped tree, slice wire) | **works** |
41
+ | Failure wire | **works** |
42
+ | Embedded tests: accept / reject / capture | **works** — all 125 corpus cases replay with zero failures |
43
+ | Embedded tests: example / shape | skipped (17 cases) — needs the bindings + flattening port ([ticket 0003](docs/tickets/0003-bindings-and-flattening.md)) |
44
+ | `parse_recovering` | not yet — needs a C-ABI export ([ticket 0002](docs/tickets/0002-recovery-export.md)) |
45
+ | Dynamic / runtime-state artifacts | rejected upstream by contract (Ruby-tier) |
46
+ | Wheels on PyPI | not yet ([ticket 0001](docs/tickets/0001-packaging.md)) |
47
+
48
+ ## Development
49
+
50
+ ```sh
51
+ cargo build -p parsanol-ffi --release # in parsanol-rs
52
+ export PARSANOL_CDYLIB=$RS/target/release/libparsanol.dylib
53
+ ln -s ../parsanol-rs/corpus corpus # for the corpus gate
54
+ python3 -m pip install -e . pytest
55
+ python3 -m pytest tests -q
56
+ ```
57
+
58
+ The corpus gate (`tests/test_corpus.py`) pins the sizes: 125 cases, 17
59
+ skips — when either moves, this repo moves with it deliberately.
@@ -0,0 +1,46 @@
1
+ # parsanol (Python)
2
+
3
+ Python runtime for **PARG artifacts** — checksummed `parsanol-tree/v2`
4
+ envelopes that Ruby, Rust, WebAssembly, TypeScript, and now Python all
5
+ parse identically.
6
+
7
+ The engine is the real one: this package drives `libparsanol` (the
8
+ pure-portable C ABI cdylib, the same binary the Ruby ffi tier ships) via
9
+ `ctypes`. No second parser, no reimplementation.
10
+
11
+ ```python
12
+ from parsanol import PargArtifact
13
+
14
+ artifact = PargArtifact.from_path("demo.artifact.json") # verifies sha256
15
+ tree = artifact.parse("iso 12345", "identifier") # shaped tree
16
+ run = artifact.run_tests() # embedded tests replayed
17
+ ```
18
+
19
+ On rejection, `parse` raises `ParseFailed` carrying the engine's failure
20
+ wire (`offset`, ranked positions, expected set).
21
+
22
+ ## Status and honest boundaries (v0.1)
23
+
24
+ | Capability | State |
25
+ |---|---|
26
+ | Artifact load + sha256 verification | **works** — the canonical-form port is byte-exact against the reference compiler (all 32 corpus checksums verify) |
27
+ | Native parse (shaped tree, slice wire) | **works** |
28
+ | Failure wire | **works** |
29
+ | Embedded tests: accept / reject / capture | **works** — all 125 corpus cases replay with zero failures |
30
+ | Embedded tests: example / shape | skipped (17 cases) — needs the bindings + flattening port ([ticket 0003](docs/tickets/0003-bindings-and-flattening.md)) |
31
+ | `parse_recovering` | not yet — needs a C-ABI export ([ticket 0002](docs/tickets/0002-recovery-export.md)) |
32
+ | Dynamic / runtime-state artifacts | rejected upstream by contract (Ruby-tier) |
33
+ | Wheels on PyPI | not yet ([ticket 0001](docs/tickets/0001-packaging.md)) |
34
+
35
+ ## Development
36
+
37
+ ```sh
38
+ cargo build -p parsanol-ffi --release # in parsanol-rs
39
+ export PARSANOL_CDYLIB=$RS/target/release/libparsanol.dylib
40
+ ln -s ../parsanol-rs/corpus corpus # for the corpus gate
41
+ python3 -m pip install -e . pytest
42
+ python3 -m pytest tests -q
43
+ ```
44
+
45
+ The corpus gate (`tests/test_corpus.py`) pins the sizes: 125 cases, 17
46
+ skips — when either moves, this repo moves with it deliberately.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "parsanol"
7
+ version = "0.1.0"
8
+ description = "Python runtime for parsanol PARG artifacts: checksum-verified artifact load and wasm-grade parsing through the parsanol C ABI"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ # NOTE: the Python floor and the PyPI name are OWNER decisions (see
13
+ # docs/tickets/0001-packaging.md); this skeleton uses the conservative
14
+ # default so CI can run everywhere.
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Compilers",
18
+ "Topic :: Text Processing :: General",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://www.parsanol.org"
23
+ Repository = "https://github.com/parsanol/parsanol-python"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ parsanol = ["lib/*.so", "lib/*.dylib", "lib/*.dll"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ """parsanol — Python runtime for PARG artifacts.
2
+
3
+ Loads checksummed parsanol-tree/v2 artifacts and parses through the parsanol
4
+ C ABI (the same engine the Ruby, Rust, wasm, and TypeScript runtimes use).
5
+ """
6
+
7
+ from .artifact import ParseFailed, PargArtifact, TestRun, apply_bindings
8
+ from .bindings import Bindings, BindingsError, canonical as canonical_value, collect_captures
9
+ from .canonical import canonical_json, checksum_hex, ruby_json_float, verify_checksum
10
+ from ._ffi import ParsanolError, engine_version, load
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "Bindings",
16
+ "BindingsError",
17
+ "PargArtifact",
18
+ "ParseFailed",
19
+ "ParsanolError",
20
+ "TestRun",
21
+ "apply_bindings",
22
+ "canonical_json",
23
+ "canonical_value",
24
+ "checksum_hex",
25
+ "collect_captures",
26
+ "engine_version",
27
+ "load",
28
+ "ruby_json_float",
29
+ "verify_checksum",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,166 @@
1
+ """ctypes binding for the parsanol C ABI (libparsanol cdylib).
2
+
3
+ The C surface is the same pure-portable ABI the Ruby ffi tier drives:
4
+ `parsanol_parg_parse` returns the shaped tree as JSON on success (null on
5
+ failure), `parsanol_parg_error` returns the failure wire as JSON, and
6
+ `parsanol_parg_free` releases both. Nothing here interprets trees — that is
7
+ `parsanol.artifact`'s job.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ctypes
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+
17
+ _LIB_NAMES = (
18
+ "libparsanol.dylib",
19
+ "libparsanol.so",
20
+ "parsanol.dll", # cargo emits no lib prefix on windows
21
+ "libparsanol.dll",
22
+ )
23
+
24
+
25
+ class ParsanolError(RuntimeError):
26
+ """The engine reported a hard error (registration, loading, ABI)."""
27
+
28
+
29
+ def _candidate_paths() -> list[str]:
30
+ candidates: list[str] = []
31
+ env = os.environ.get("PARSANOL_CDYLIB")
32
+ if env:
33
+ candidates.append(env)
34
+ packaged = Path(__file__).resolve().parent / "lib"
35
+ for name in _LIB_NAMES:
36
+ candidates.append(str(packaged / name))
37
+ for name in _LIB_NAMES:
38
+ candidates.append(name) # system loader search
39
+ return candidates
40
+
41
+
42
+ _lib: ctypes.CDLL | None = None
43
+ _version: str | None = None
44
+
45
+
46
+ def load() -> ctypes.CDLL:
47
+ """dlopen the cdylib once and bind the entry points."""
48
+ global _lib, _version
49
+ if _lib is not None:
50
+ return _lib
51
+
52
+ errors: list[str] = []
53
+ lib: ctypes.CDLL | None = None
54
+ for candidate in _candidate_paths():
55
+ try:
56
+ lib = ctypes.CDLL(candidate)
57
+ break
58
+ except OSError as error: # noqa: PERF203 - small fixed list
59
+ errors.append(f"{candidate}: {error}")
60
+ if lib is None:
61
+ raise ParsanolError(
62
+ "libparsanol not found (build it with `cargo build -p parsanol-ffi "
63
+ "--release` and point PARSANOL_CDYLIB at the dylib, or install a "
64
+ "wheel that vendors it). Tried:\n " + "\n ".join(errors)
65
+ )
66
+
67
+ lib.parsanol_parg_parse.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
68
+ lib.parsanol_parg_parse.restype = ctypes.c_void_p
69
+ lib.parsanol_parg_error.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
70
+ lib.parsanol_parg_error.restype = ctypes.c_void_p
71
+ lib.parsanol_parg_free.argtypes = [ctypes.c_void_p]
72
+ lib.parsanol_parg_free.restype = None
73
+ # parsanol >= 0.18.19: the recovery export. Optional so an older cdylib
74
+ # still loads; parse_recovering reports availability instead.
75
+ if hasattr(lib, "parsanol_parg_parse_recovering"):
76
+ lib.parsanol_parg_parse_recovering.argtypes = [
77
+ ctypes.c_char_p,
78
+ ctypes.c_char_p,
79
+ ctypes.c_char_p,
80
+ ]
81
+ lib.parsanol_parg_parse_recovering.restype = ctypes.c_void_p
82
+ # STATIC string — must never be freed (restype c_char_p copies it).
83
+ lib.parsanol_version.argtypes = []
84
+ lib.parsanol_version.restype = ctypes.c_char_p
85
+ lib.parsanol_c_last_error.argtypes = []
86
+ # STATIC string — must never be freed.
87
+ lib.parsanol_c_last_error.restype = ctypes.c_char_p
88
+
89
+ _lib = lib
90
+ _version = (lib.parsanol_version() or b"").decode("utf-8", errors="replace")
91
+ return lib
92
+
93
+
94
+ def engine_version() -> str:
95
+ """The parsanol crate version compiled into the loaded cdylib."""
96
+ load()
97
+ assert _version is not None
98
+ return _version
99
+
100
+
101
+ def _last_error() -> str:
102
+ assert _lib is not None
103
+ pointer = _lib.parsanol_c_last_error()
104
+ if not pointer:
105
+ return ""
106
+ return pointer.decode("utf-8", errors="replace")
107
+
108
+
109
+ def _take_owned(pointer: int | None):
110
+ """Decode an OWNED C string (parse/error results) and free it."""
111
+ assert _lib is not None
112
+ if not pointer:
113
+ raise ParsanolError(_last_error() or "null result with no error text")
114
+ text = ctypes.string_at(pointer).decode("utf-8", errors="replace")
115
+ _lib.parsanol_parg_free(ctypes.c_void_p(pointer))
116
+ return text
117
+
118
+
119
+ def parse_shaped(grammar_json: str, input_text: str) -> dict | list | str | None:
120
+ """Shaped (parsanol-shape) tree as Python objects; None on rejection."""
121
+ lib = load()
122
+ pointer = lib.parsanol_parg_parse(
123
+ grammar_json.encode("utf-8"), input_text.encode("utf-8")
124
+ )
125
+ if not pointer:
126
+ return None
127
+ return json.loads(_take_owned(pointer))
128
+
129
+
130
+ def failure_wire(grammar_json: str, input_text: str) -> dict:
131
+ """The failure wire (offset, expected, ...) for a rejected input."""
132
+ lib = load()
133
+ pointer = lib.parsanol_parg_error(
134
+ grammar_json.encode("utf-8"), input_text.encode("utf-8")
135
+ )
136
+ if not pointer:
137
+ raise ParsanolError(_last_error() or "failure wire unavailable")
138
+ result = json.loads(_take_owned(pointer))
139
+ return result if isinstance(result, dict) else {}
140
+
141
+
142
+ def recovery_available() -> bool:
143
+ """Whether the loaded cdylib exports the recovery ABI (>= 0.18.19)."""
144
+ return hasattr(load(), "parsanol_parg_parse_recovering")
145
+
146
+
147
+ def parse_recovering(
148
+ envelope_json: str, entry: str | None, input_text: str
149
+ ) -> dict:
150
+ """The recovery envelope for an artifact envelope (checksum verified
151
+ engine-side). Requires a cdylib with the recovery export."""
152
+ lib = load()
153
+ if not hasattr(lib, "parsanol_parg_parse_recovering"):
154
+ raise ParsanolError(
155
+ "the loaded libparsanol lacks parsanol_parg_parse_recovering "
156
+ "(requires parsanol >= 0.18.19)"
157
+ )
158
+ pointer = lib.parsanol_parg_parse_recovering(
159
+ envelope_json.encode("utf-8"),
160
+ (entry.encode("utf-8") if entry is not None else None),
161
+ input_text.encode("utf-8"),
162
+ )
163
+ if not pointer:
164
+ raise ParsanolError(_last_error() or "recovery envelope unavailable")
165
+ result = json.loads(_take_owned(pointer))
166
+ return result if isinstance(result, dict) else {}
@@ -0,0 +1,217 @@
1
+ """PargArtifact: checksum-verified artifact load and native parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from . import _ffi
10
+ from .bindings import Bindings, canonical
11
+ from .canonical import verify_checksum
12
+
13
+
14
+ def apply_bindings(artifact: "PargArtifact", entry: str, shape) -> dict:
15
+ """Apply an entry's bindings to a shaped tree (the `example` layer)."""
16
+ return Bindings(artifact.envelope, entry).apply(shape)
17
+
18
+
19
+ class ParseFailed(Exception):
20
+ """The input was rejected; `wire` carries the failure wire."""
21
+
22
+ def __init__(self, wire: dict):
23
+ self.wire = wire
24
+ offset = wire.get("offset")
25
+ expected = wire.get("expected") or []
26
+ what = ", ".join(expected[:4]) if expected else "no further input"
27
+ super().__init__(f"Failed to match at offset {offset}: expected {what}")
28
+
29
+
30
+ @dataclass
31
+ class TestRun:
32
+ """Result of replaying an artifact's embedded tests."""
33
+
34
+ total: int
35
+ failures: list[str]
36
+ skipped: list[str]
37
+
38
+ @property
39
+ def ok(self) -> bool:
40
+ return not self.failures
41
+
42
+
43
+ class PargArtifact:
44
+ """A loaded parsanol-tree/v2 artifact.
45
+
46
+ Load verifies the sha256 checksum over the canonicalized envelope and
47
+ fails loudly on mismatch — never silently mis-parses.
48
+ """
49
+
50
+ def __init__(self, envelope_text: str):
51
+ self.envelope_text = envelope_text
52
+ envelope = json.loads(envelope_text)
53
+ verify_checksum(envelope)
54
+ self.envelope = envelope
55
+ entries = envelope.get("entries") or {}
56
+ default = envelope.get("default_entry")
57
+ if default is None and entries:
58
+ default = sorted(entries)[0]
59
+ self.default_entry: str | None = default
60
+
61
+ @classmethod
62
+ def from_path(cls, path: str | Path) -> "PargArtifact":
63
+ return cls(Path(path).read_text(encoding="utf-8"))
64
+
65
+ # -- structure ---------------------------------------------------------
66
+
67
+ @property
68
+ def checksum(self) -> str | None:
69
+ return self.envelope.get("checksum")
70
+
71
+ def entry_names(self) -> list[str]:
72
+ return sorted(self.envelope.get("entries") or {})
73
+
74
+ def _entry(self, entry: str | None) -> tuple[str, dict]:
75
+ entries = self.envelope.get("entries") or {}
76
+ name = entry or self.default_entry
77
+ if name is None or name not in entries:
78
+ raise KeyError(f"unknown entry {name!r}")
79
+ return name, entries[name]
80
+
81
+ def _grammar_json(self, entry: str | None) -> str:
82
+ _, resolved = self._entry(entry)
83
+ grammar = resolved.get("grammar")
84
+ if grammar is None:
85
+ raise KeyError(f"entry {entry!r} has no portable grammar")
86
+ return json.dumps(grammar, separators=(",", ":"), sort_keys=True)
87
+
88
+ # -- parsing -----------------------------------------------------------
89
+
90
+ def parse(self, input_text: str, entry: str | None = None):
91
+ """Shaped tree (Python objects) or raises ParseFailed."""
92
+ grammar_json = self._grammar_json(entry)
93
+ shaped = _ffi.parse_shaped(grammar_json, input_text)
94
+ if shaped is None:
95
+ raise ParseFailed(_ffi.failure_wire(grammar_json, input_text))
96
+ return shaped
97
+
98
+ def parse_wire(self, input_text: str, entry: str | None = None) -> dict:
99
+ """The failure wire for a rejected input (no exception)."""
100
+ return _ffi.failure_wire(self._grammar_json(entry), input_text)
101
+
102
+ def recovery_supported(self) -> bool:
103
+ """Whether the loaded engine exports the recovery ABI."""
104
+ return _ffi.recovery_available()
105
+
106
+ def parse_recovering(self, input_text: str, entry: str | None = None) -> dict:
107
+ """Best-effort recovery (never raises): `{ok: true, shape}` on
108
+ success; `{ok: false, partial, error, resyncs, captures,
109
+ consumed_upto}` on failure — the same envelope every other runtime
110
+ returns, declared resync spans included. The envelope is
111
+ checksum-verified engine-side. Requires parsanol >= 0.18.19."""
112
+ self._entry(entry) # entry validation before the FFI call
113
+ return _ffi.parse_recovering(self.envelope_text, entry, input_text)
114
+
115
+ # -- embedded tests ------------------------------------------------------
116
+
117
+ def run_tests(self, entry: str | None = None) -> TestRun:
118
+ """Replay the artifact's embedded tests through the native engine.
119
+
120
+ Phases: accept / reject / capture (grammar layer) / example
121
+ (bindings layer) / shape (whole-tree layer) — the same five the
122
+ Ruby and Rust runners evaluate.
123
+ """
124
+ name, _resolved = self._entry(entry)
125
+ failures: list[str] = []
126
+ skipped: list[str] = []
127
+ total = 0
128
+ for test in self.envelope.get("tests") or []:
129
+ test_entry = test.get("entry") or name
130
+ if test_entry != name:
131
+ continue
132
+ total += 1
133
+ kind = test.get("kind")
134
+ text = test.get("input") or ""
135
+ label = f"{kind} {text!r}"
136
+ if kind not in ("accept", "reject", "capture", "example", "shape"):
137
+ skipped.append(f"{label}: {kind} not in the Python scope")
138
+ continue
139
+ try:
140
+ tree = self.parse(text, name)
141
+ except ParseFailed:
142
+ if kind != "reject":
143
+ failures.append(f"{label}: expected a match, engine rejected")
144
+ continue
145
+ if kind == "reject":
146
+ failures.append(f"{label}: expected rejection, engine matched")
147
+ continue
148
+
149
+ reduced = reduce_slices(tree)
150
+ if kind == "capture":
151
+ problems = _check_capture(reduced, test.get("expect") or {})
152
+ failures.extend(f"{label}: {problem}" for problem in problems)
153
+ elif kind == "example":
154
+ problems = _check_example(self, name, reduced, test.get("expect") or {})
155
+ failures.extend(f"{label}: {problem}" for problem in problems)
156
+ elif kind == "shape":
157
+ if canonical(reduced) != canonical(test.get("expect")):
158
+ failures.append(
159
+ f"{label}: shape mismatch: expected {test.get('expect')!r}, "
160
+ f"got {reduced!r}"
161
+ )
162
+ return TestRun(total=total, failures=failures, skipped=skipped)
163
+
164
+
165
+ def _check_example(artifact: "PargArtifact", entry: str, reduced, expect: dict) -> list[str]:
166
+ if not isinstance(expect, dict):
167
+ return [f"example expectations must be an object, got {expect!r}"]
168
+ try:
169
+ bound = apply_bindings(artifact, entry, reduced)
170
+ except Exception as error: # bindings errors are test failures
171
+ return [f"bindings failed: {error}"]
172
+ problems: list[str] = []
173
+ for key, want in expect.items():
174
+ got = bound.get(key)
175
+ if canonical(got) != canonical(want):
176
+ problems.append(f"bound {key!r}: expected {want!r}, got {got!r}")
177
+ return problems
178
+
179
+
180
+ _SLICE_KEYS = {"offset", "length", "line", "column", "value"}
181
+
182
+
183
+ def reduce_slices(tree):
184
+ """Reduce slice wire objects ({offset, length, line, column, value}) to
185
+ their text — the comparison form the capture-test semantics expect.
186
+ Other dicts pass through untouched."""
187
+ if isinstance(tree, dict):
188
+ if set(tree) == _SLICE_KEYS:
189
+ return tree["value"]
190
+ return {key: reduce_slices(value) for key, value in tree.items()}
191
+ if isinstance(tree, list):
192
+ return [reduce_slices(item) for item in tree]
193
+ return tree
194
+
195
+
196
+ def _check_capture(tree, expect: dict) -> list[str]:
197
+ """Corpus capture semantics: collect named captures from anywhere in
198
+ the flattened tree (first value wins) and compare presence + value."""
199
+ problems: list[str] = []
200
+ captures: dict = {}
201
+
202
+ def walk(value) -> None:
203
+ if isinstance(value, dict):
204
+ for key, item in value.items():
205
+ captures.setdefault(key, item)
206
+ walk(item)
207
+ elif isinstance(value, list):
208
+ for item in value:
209
+ walk(item)
210
+
211
+ walk(tree)
212
+ for key, want in expect.items():
213
+ if key not in captures:
214
+ problems.append(f"capture {key!r} missing from tree")
215
+ elif captures[key] != want:
216
+ problems.append(f"capture {key!r}: expected {want!r}, got {captures[key]!r}")
217
+ return problems
@@ -0,0 +1,259 @@
1
+ """Bindings application — a faithful port of `parsanol::parg::bindings`.
2
+
3
+ Pipeline: capture collection (BFS over the shaped tree, identical order)
4
+ → preprocessing (table_lookup over embedded table rows) → casting
5
+ (Ruby-semantics integer/float/string/boolean casts).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .canonical import ruby_json_float
13
+
14
+
15
+ class BindingsError(Exception):
16
+ pass
17
+
18
+
19
+ def is_leaf(value: Any) -> bool:
20
+ """Capture leaves arrive as {value, line, column, offset, length}."""
21
+ return isinstance(value, dict) and "value" in value
22
+
23
+
24
+ def leaf_scalar(value: Any) -> Any:
25
+ return value["value"] if is_leaf(value) else value
26
+
27
+
28
+ def collect_captures(shape: Any) -> dict[str, list[Any]]:
29
+ """BFS capture collection, identical traversal order to Ruby's queue."""
30
+ found: dict[str, list[Any]] = {}
31
+ queue: list[Any] = [shape]
32
+ while queue:
33
+ node = queue.pop(0)
34
+ if isinstance(node, dict):
35
+ for key, value in node.items():
36
+ found.setdefault(key, []).append(value)
37
+ if not is_leaf(value):
38
+ queue.append(value)
39
+ elif isinstance(node, list):
40
+ queue.extend(node)
41
+ return found
42
+
43
+
44
+ def to_ruby_string(value: Any) -> str:
45
+ """Ruby `value.to_s` for the JSON values bindings see."""
46
+ value = leaf_scalar(value)
47
+ if isinstance(value, str):
48
+ return value
49
+ if isinstance(value, bool):
50
+ return "true" if value else "false"
51
+ if isinstance(value, int):
52
+ return str(value)
53
+ if isinstance(value, float):
54
+ if value.is_integer() and math_finite(value):
55
+ return f"{value:.1f}"
56
+ return repr(value)
57
+ if value is None:
58
+ return ""
59
+ return ""
60
+
61
+
62
+ def math_finite(value: float) -> bool:
63
+ import math
64
+
65
+ return math.isfinite(value)
66
+
67
+
68
+ def _to_int(text: str) -> int:
69
+ negative = text.startswith("-")
70
+ body = text[1:] if negative else text
71
+ if body.startswith("0x"):
72
+ radix, digits = 16, body[2:]
73
+ elif body.startswith("0b"):
74
+ radix, digits = 2, body[2:]
75
+ elif body.startswith("0o"):
76
+ radix, digits = 8, body[2:]
77
+ else:
78
+ radix, digits = 10, body
79
+ try:
80
+ magnitude = int(digits.replace("_", ""), radix)
81
+ except ValueError as error:
82
+ raise BindingsError(f"cannot cast {text!r} to integer") from error
83
+ return -magnitude if negative else magnitude
84
+
85
+
86
+ def cast_integer(value: Any) -> int:
87
+ value = leaf_scalar(value)
88
+ if isinstance(value, bool):
89
+ raise BindingsError(f"cannot cast {value!r} to integer")
90
+ if isinstance(value, int):
91
+ return value
92
+ if isinstance(value, str):
93
+ result = _to_int(value)
94
+ if not (-(2**63) <= result < 2**63):
95
+ raise BindingsError(f"integer out of i64 range: {text_safe(value)}")
96
+ return result
97
+ raise BindingsError(f"cannot cast {value!r} to integer")
98
+
99
+
100
+ def text_safe(value: str) -> str:
101
+ return value
102
+
103
+
104
+ def cast_float(value: Any) -> float:
105
+ value = leaf_scalar(value)
106
+ if isinstance(value, bool):
107
+ raise BindingsError(f"cannot cast {value!r} to float")
108
+ if isinstance(value, (int, float)):
109
+ return float(value)
110
+ if isinstance(value, str):
111
+ try:
112
+ return float(value.strip())
113
+ except ValueError as error:
114
+ raise BindingsError(f"cannot cast {value!r} to float") from error
115
+ raise BindingsError(f"cannot cast {value!r} to float")
116
+
117
+
118
+ def cast(value: Any, kind: str | None) -> Any:
119
+ value = leaf_scalar(value)
120
+ if kind == "integer":
121
+ return cast_integer(value)
122
+ if kind == "float":
123
+ return cast_float(value)
124
+ if kind == "string":
125
+ if isinstance(value, (str, int, float, bool)) or value is None:
126
+ return to_ruby_string(value)
127
+ raise BindingsError(f"cannot cast {value!r} to string")
128
+ if kind == "boolean":
129
+ return value is True or (
130
+ isinstance(value, str) and value.lower() == "true"
131
+ )
132
+ return value
133
+
134
+
135
+ def canonical(value: Any) -> Any:
136
+ """The suite's comparison form: floats render as their B6 text, leaves
137
+ reduce to their scalar."""
138
+ if isinstance(value, float) and not isinstance(value, bool):
139
+ return ruby_json_float(value)
140
+ if isinstance(value, dict):
141
+ if is_leaf(value):
142
+ return canonical(leaf_scalar(value))
143
+ return {key: canonical(item) for key, item in value.items()}
144
+ if isinstance(value, list):
145
+ return [canonical(item) for item in value]
146
+ return value
147
+
148
+
149
+ class Bindings:
150
+ """An entry's binding list with the embedded preprocess tables."""
151
+
152
+ def __init__(self, envelope: dict, entry: str):
153
+ self.envelope = envelope
154
+ entry_value = (envelope.get("entries") or {}).get(entry) or {}
155
+ self.list: list[dict] = entry_value.get("bindings") or []
156
+
157
+ def _preprocess_steps(self, name: str) -> list[dict]:
158
+ return ((self.envelope.get("preprocess") or {}).get(name)) or []
159
+
160
+ def _table_rows(self, name: str) -> list[dict]:
161
+ declared = (self.envelope.get("tables") or {}).get(name)
162
+ if not isinstance(declared, dict):
163
+ raise BindingsError(f"unknown table {name!r}")
164
+ rows = declared.get("rows")
165
+ if not isinstance(rows, list):
166
+ raise BindingsError(f"unknown table {name!r}")
167
+ return rows
168
+
169
+ def _preprocess(self, binding: dict, value: Any) -> Any:
170
+ name = binding.get("preprocess")
171
+ if not isinstance(name, str):
172
+ return value
173
+ for step in self._preprocess_steps(name):
174
+ op = step.get("op")
175
+ if op != "table_lookup":
176
+ raise BindingsError(f"unknown preprocess op {op!r}")
177
+ try:
178
+ table = step["table"]
179
+ source = step["from"]
180
+ target = step["to"]
181
+ except KeyError as error:
182
+ raise BindingsError(f"unknown preprocess op {op!r}") from error
183
+ needle = to_ruby_string(value)
184
+ mapped = None
185
+ for row in self._table_rows(table):
186
+ if not isinstance(row, dict):
187
+ continue
188
+ if to_ruby_string(row.get(source)) == needle:
189
+ mapped = row.get(target)
190
+ break
191
+ if mapped is not None:
192
+ value = mapped
193
+ return value
194
+
195
+ def _finalize(self, binding: dict, value: Any) -> Any:
196
+ return cast(self._preprocess(binding, value), binding.get("type"))
197
+
198
+ @staticmethod
199
+ def _leaf_key(path: str) -> str:
200
+ if "[]" in path:
201
+ return path.rsplit("].", 1)[-1]
202
+ return path
203
+
204
+ def _assign_scalar(self, out: dict, binding: dict, captures: dict) -> None:
205
+ capture = binding.get("capture") or ""
206
+ path = binding.get("path") or ""
207
+ if "." in path or "[" in path:
208
+ raise BindingsError(f"nested binding path {path!r} for capture {capture!r}")
209
+ values = captures.get(capture)
210
+ if not values:
211
+ return
212
+ out[self._leaf_key(path)] = self._finalize(binding, values[0])
213
+
214
+ def _assign_array(self, out: dict, prefix: str, group: list[dict], captures: dict) -> None:
215
+ count = max(
216
+ (len(captures[b.get("capture") or ""]) for b in group if (b.get("capture") or "") in captures),
217
+ default=0,
218
+ )
219
+ if count == 0:
220
+ return
221
+ list_out: list[dict] = []
222
+ for index in range(count):
223
+ element: dict = {}
224
+ for binding in group:
225
+ capture = binding.get("capture") or ""
226
+ values = captures.get(capture)
227
+ if values is None or index >= len(values):
228
+ continue
229
+ element[self._leaf_key(binding.get("path") or "")] = self._finalize(
230
+ binding, values[index]
231
+ )
232
+ list_out.append(element)
233
+ out[prefix] = list_out
234
+
235
+ def apply(self, shape: Any) -> dict:
236
+ captures = collect_captures(shape)
237
+ scalars: list[dict] = []
238
+ arrays: list[tuple[str, dict]] = []
239
+ for binding in self.list:
240
+ if not isinstance(binding, dict):
241
+ continue
242
+ path = binding.get("path") or ""
243
+ if "[]" in path:
244
+ arrays.append((path.split("[]", 1)[0], binding))
245
+ else:
246
+ scalars.append(binding)
247
+
248
+ out: dict = {}
249
+ for binding in scalars:
250
+ self._assign_scalar(out, binding, captures)
251
+ # Ruby groups by prefix while preserving first-seen order.
252
+ order: list[str] = []
253
+ for prefix, _ in arrays:
254
+ if prefix not in order:
255
+ order.append(prefix)
256
+ for prefix in order:
257
+ group = [binding for candidate, binding in arrays if candidate == prefix]
258
+ self._assign_array(out, prefix, group, captures)
259
+ return out
@@ -0,0 +1,136 @@
1
+ """Canonical JSON and artifact checksums — a faithful port of the Rust
2
+ reference (`parsanol::parg::canonical_json` / `checksum_hex`), so a Python
3
+ verification agrees byte-for-byte with the compiler's sha256.
4
+
5
+ Canonical form: object keys sorted, no whitespace, short escapes for
6
+ ``\\b \\t \\n \\f \\r " \\``, lowercase ``\\u00xx`` for other control
7
+ characters, raw UTF-8 otherwise, and floats rendered with the B6 float form
8
+ (shortest round-trip digits, Ruby JSON gem notation).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import math
16
+
17
+
18
+ def ruby_json_float(value: float) -> str:
19
+ """B6 float form: shortest round-trip digits, Ruby JSON notation."""
20
+ if value == 0.0:
21
+ return "-0.0" if math.copysign(1.0, value) < 0 else "0.0"
22
+ sign = "-" if value < 0 else ""
23
+
24
+ digits = exponent = None
25
+ for precision in range(18):
26
+ text = f"{value:.{precision}e}"
27
+ if float(text) == value:
28
+ mantissa, raw_exponent = text.split("e")
29
+ stripped = "".join(c for c in mantissa if c.isdigit()).rstrip("0")
30
+ digits = stripped or "0"
31
+ exponent = int(raw_exponent)
32
+ break
33
+ assert digits is not None and exponent is not None, f"unformattable float {value!r}"
34
+
35
+ if not (-9 <= exponent <= 14):
36
+ head, tail = digits[:1], digits[1:]
37
+ mantissa = head if not tail else f"{head}.{tail}"
38
+ return f"{sign}{mantissa}e{exponent:+03d}"
39
+
40
+ if exponent >= 0:
41
+ point = exponent + 1
42
+ if point >= len(digits):
43
+ return f"{sign}{digits}{'0' * (point - len(digits))}.0"
44
+ return f"{sign}{digits[:point]}.{digits[point:]}"
45
+ zeros = "0" * (-exponent - 1)
46
+ return f"{sign}0.{zeros}{digits}"
47
+
48
+
49
+ def _write_canonical(value, out: list[str]) -> None:
50
+ if value is None:
51
+ out.append("null")
52
+ elif value is True:
53
+ out.append("true")
54
+ elif value is False:
55
+ out.append("false")
56
+ elif isinstance(value, float):
57
+ out.append(ruby_json_float(value))
58
+ elif isinstance(value, int):
59
+ out.append(str(value))
60
+ elif isinstance(value, str):
61
+ _write_canonical_string(value, out)
62
+ elif isinstance(value, list):
63
+ out.append("[")
64
+ for index, item in enumerate(value):
65
+ if index:
66
+ out.append(",")
67
+ _write_canonical(item, out)
68
+ out.append("]")
69
+ elif isinstance(value, dict):
70
+ out.append("{")
71
+ for index, key in enumerate(sorted(value)):
72
+ if index:
73
+ out.append(",")
74
+ _write_canonical_string(key, out)
75
+ out.append(":")
76
+ _write_canonical(value[key], out)
77
+ out.append("}")
78
+ else: # pragma: no cover - envelopes are plain JSON by construction
79
+ raise TypeError(f"non-JSON value in envelope: {type(value)!r}")
80
+
81
+
82
+ def _write_canonical_string(text: str, out: list[str]) -> None:
83
+ out.append('"')
84
+ for character in text:
85
+ if character == '"':
86
+ out.append('\\"')
87
+ elif character == "\\":
88
+ out.append("\\\\")
89
+ elif character == "\b":
90
+ out.append("\\b")
91
+ elif character == "\f":
92
+ out.append("\\f")
93
+ elif character == "\n":
94
+ out.append("\\n")
95
+ elif character == "\r":
96
+ out.append("\\r")
97
+ elif character == "\t":
98
+ out.append("\\t")
99
+ elif ord(character) < 0x20:
100
+ out.append(f"\\u{ord(character):04x}")
101
+ else:
102
+ out.append(character)
103
+ out.append('"')
104
+
105
+
106
+ def canonical_json(value) -> str:
107
+ out: list[str] = []
108
+ _write_canonical(value, out)
109
+ return "".join(out)
110
+
111
+
112
+ def canonical_from_json_text(text: str):
113
+ """Parse JSON text the way the canonical form expects.
114
+
115
+ `json.loads` already produces shortest-round-trip floats (repr-based),
116
+ and `ruby_json_float` re-derives the digits, so plain parsing preserves
117
+ the B6 form.
118
+ """
119
+ return json.loads(text)
120
+
121
+
122
+ def checksum_hex(payload) -> str:
123
+ """`sha256:<hex>` over the canonical form of `payload`."""
124
+ digest = hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
125
+ return f"sha256:{digest}"
126
+
127
+
128
+ def verify_checksum(envelope: dict) -> None:
129
+ """Raise ValueError when the envelope's checksum does not verify."""
130
+ stored = envelope.get("checksum")
131
+ if not isinstance(stored, str):
132
+ raise ValueError("envelope has no checksum")
133
+ payload = {key: value for key, value in envelope.items() if key != "checksum"}
134
+ computed = checksum_hex(payload)
135
+ if stored != computed:
136
+ raise ValueError(f"checksum mismatch: stored {stored}, computed {computed}")
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: parsanol
3
+ Version: 0.1.0
4
+ Summary: Python runtime for parsanol PARG artifacts: checksum-verified artifact load and wasm-grade parsing through the parsanol C ABI
5
+ License: MIT
6
+ Project-URL: Homepage, https://www.parsanol.org
7
+ Project-URL: Repository, https://github.com/parsanol/parsanol-python
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Software Development :: Compilers
10
+ Classifier: Topic :: Text Processing :: General
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # parsanol (Python)
15
+
16
+ Python runtime for **PARG artifacts** — checksummed `parsanol-tree/v2`
17
+ envelopes that Ruby, Rust, WebAssembly, TypeScript, and now Python all
18
+ parse identically.
19
+
20
+ The engine is the real one: this package drives `libparsanol` (the
21
+ pure-portable C ABI cdylib, the same binary the Ruby ffi tier ships) via
22
+ `ctypes`. No second parser, no reimplementation.
23
+
24
+ ```python
25
+ from parsanol import PargArtifact
26
+
27
+ artifact = PargArtifact.from_path("demo.artifact.json") # verifies sha256
28
+ tree = artifact.parse("iso 12345", "identifier") # shaped tree
29
+ run = artifact.run_tests() # embedded tests replayed
30
+ ```
31
+
32
+ On rejection, `parse` raises `ParseFailed` carrying the engine's failure
33
+ wire (`offset`, ranked positions, expected set).
34
+
35
+ ## Status and honest boundaries (v0.1)
36
+
37
+ | Capability | State |
38
+ |---|---|
39
+ | Artifact load + sha256 verification | **works** — the canonical-form port is byte-exact against the reference compiler (all 32 corpus checksums verify) |
40
+ | Native parse (shaped tree, slice wire) | **works** |
41
+ | Failure wire | **works** |
42
+ | Embedded tests: accept / reject / capture | **works** — all 125 corpus cases replay with zero failures |
43
+ | Embedded tests: example / shape | skipped (17 cases) — needs the bindings + flattening port ([ticket 0003](docs/tickets/0003-bindings-and-flattening.md)) |
44
+ | `parse_recovering` | not yet — needs a C-ABI export ([ticket 0002](docs/tickets/0002-recovery-export.md)) |
45
+ | Dynamic / runtime-state artifacts | rejected upstream by contract (Ruby-tier) |
46
+ | Wheels on PyPI | not yet ([ticket 0001](docs/tickets/0001-packaging.md)) |
47
+
48
+ ## Development
49
+
50
+ ```sh
51
+ cargo build -p parsanol-ffi --release # in parsanol-rs
52
+ export PARSANOL_CDYLIB=$RS/target/release/libparsanol.dylib
53
+ ln -s ../parsanol-rs/corpus corpus # for the corpus gate
54
+ python3 -m pip install -e . pytest
55
+ python3 -m pytest tests -q
56
+ ```
57
+
58
+ The corpus gate (`tests/test_corpus.py`) pins the sizes: 125 cases, 17
59
+ skips — when either moves, this repo moves with it deliberately.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/parsanol/__init__.py
4
+ src/parsanol/_ffi.py
5
+ src/parsanol/artifact.py
6
+ src/parsanol/bindings.py
7
+ src/parsanol/canonical.py
8
+ src/parsanol.egg-info/PKG-INFO
9
+ src/parsanol.egg-info/SOURCES.txt
10
+ src/parsanol.egg-info/dependency_links.txt
11
+ src/parsanol.egg-info/top_level.txt
12
+ tests/test_binding.py
13
+ tests/test_corpus.py
14
+ tests/test_recovery.py
@@ -0,0 +1 @@
1
+ parsanol
@@ -0,0 +1,93 @@
1
+ """Binding smoke tests: engine load, shaped parse, failure wire, float form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from parsanol import PargArtifact, ParseFailed, canonical_json, engine_version, ruby_json_float
8
+
9
+ GRAMMAR = '{"root": 0, "atoms": [{"Str": {"pattern": "ab"}}]}'
10
+
11
+
12
+ def test_engine_loads_and_reports_version() -> None:
13
+ version = engine_version()
14
+ assert version
15
+ assert version.split(".")[0].isdigit()
16
+
17
+
18
+ def test_shaped_parse_success() -> None:
19
+ from parsanol import _ffi
20
+
21
+ shaped = _ffi.parse_shaped(GRAMMAR, "ab")
22
+ assert shaped is not None
23
+ assert '"value":"ab"' in canonical_json(shaped) or "ab" in str(shaped)
24
+
25
+
26
+ def test_failure_wire_carries_offset() -> None:
27
+ from parsanol import _ffi
28
+
29
+ wire = _ffi.failure_wire(GRAMMAR, "zz")
30
+ assert "offset" in wire
31
+
32
+
33
+ def test_ruby_json_float_forms() -> None:
34
+ # Ground truth ported from parsanol-rs float_tests (Ruby JSON gem
35
+ # JSON.generate on ruby 3.4, JSON gem 2.x).
36
+ cases = [
37
+ (0.1, "0.1"),
38
+ (1.5, "1.5"),
39
+ (2.0, "2.0"),
40
+ (100.0, "100.0"),
41
+ (-2.5, "-2.5"),
42
+ (-0.0, "-0.0"),
43
+ (0.0, "0.0"),
44
+ (0.0001, "0.0001"),
45
+ (1e-5, "0.00001"),
46
+ (1e-8, "0.00000001"),
47
+ (1.5e-10, "1.5e-10"),
48
+ (5e-324, "5e-324"),
49
+ (12345.6789, "12345.6789"),
50
+ (123456789012345.0, "123456789012345.0"),
51
+ (999999999999999.9, "999999999999999.9"),
52
+ (1e15, "1e+15"),
53
+ (1e16, "1e+16"),
54
+ (1e17, "1e+17"),
55
+ (1e300, "1e+300"),
56
+ (1.7976931348623157e308, "1.7976931348623157e+308"),
57
+ ]
58
+ for value, want in cases:
59
+ assert ruby_json_float(value) == want, f"{value!r}: {ruby_json_float(value)!r} != {want!r}"
60
+
61
+
62
+ def test_canonical_json_key_order_and_escapes() -> None:
63
+ assert canonical_json({"b": 1, "a": 2}) == '{"a":2,"b":1}'
64
+ assert canonical_json({"k": "a\"b\\c\n"}) == '{"k":"a\\"b\\\\c\\n"}'
65
+
66
+
67
+ def test_checksum_rejects_tampering(tmp_path) -> None:
68
+ artifact = PargArtifact.from_path(
69
+ _corpus() / "recovery_resync_declared.json"
70
+ )
71
+ tampered = dict(artifact.envelope)
72
+ tampered["grammar"] = "Tampered"
73
+ import json
74
+
75
+ with pytest.raises(ValueError, match="checksum mismatch"):
76
+ PargArtifact(json.dumps(tampered))
77
+
78
+
79
+ def test_parse_failure_raises_with_wire() -> None:
80
+ artifact = PargArtifact.from_path(_corpus() / "recovery_resync_declared.json")
81
+ with pytest.raises(ParseFailed) as excinfo:
82
+ artifact.parse("a=1;b=2;@@junk@@;c=3;d=4;", "main")
83
+ assert excinfo.value.wire.get("offset") == 8
84
+
85
+
86
+ def _corpus():
87
+ import os
88
+ from pathlib import Path
89
+
90
+ return Path(
91
+ os.environ.get("PARSANOL_CORPUS_DIR", "")
92
+ or Path(__file__).resolve().parents[1] / "corpus" / "conformance"
93
+ )
@@ -0,0 +1,54 @@
1
+ """Corpus gate: every conformance artifact verifies and replays natively.
2
+
3
+ Mirrors parsanol-rs `tests/conformance.rs` and the shipped-artifact audits:
4
+ checksum verification must agree byte-for-byte with the reference compiler,
5
+ and every embedded accept/reject/capture case must replay identically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from parsanol import PargArtifact
16
+
17
+ CORPUS_DIR = Path(
18
+ os.environ.get("PARSANOL_CORPUS_DIR", "")
19
+ or Path(__file__).resolve().parents[1] / "corpus" / "conformance"
20
+ )
21
+
22
+
23
+ def corpus_artifacts() -> list[Path]:
24
+ if not CORPUS_DIR.is_dir():
25
+ pytest.skip("corpus/conformance not present (set PARSANOL_CORPUS_DIR)")
26
+ paths = sorted(CORPUS_DIR.glob("*.json"))
27
+ assert paths, "corpus directory is empty"
28
+ return paths
29
+
30
+
31
+ def test_every_artifact_checksum_verifies() -> None:
32
+ """The Python canonical form agrees with the reference compiler's
33
+ sha256 over every corpus artifact — the B6 float form included."""
34
+ verified = 0
35
+ for path in corpus_artifacts():
36
+ artifact = PargArtifact.from_path(path)
37
+ assert artifact.checksum and artifact.checksum.startswith("sha256:")
38
+ verified += 1
39
+ assert verified >= 30, f"unexpectedly small corpus: {verified}"
40
+
41
+
42
+ def test_every_embedded_case_replays() -> None:
43
+ total = 0
44
+ skipped = 0
45
+ for path in corpus_artifacts():
46
+ artifact = PargArtifact.from_path(path)
47
+ for entry in artifact.entry_names():
48
+ run = artifact.run_tests(entry)
49
+ total += run.total
50
+ skipped += len(run.skipped)
51
+ assert not run.failures, f"{path.name}: {run.failures}"
52
+ # The full corpus is 125 cases, all five test phases now evaluated.
53
+ assert total == 125, f"corpus size moved: {total}"
54
+ assert skipped == 0, f"unexpected skips: {skipped}"
@@ -0,0 +1,67 @@
1
+ """Recovery through the C ABI: the envelope must equal the wasm wire,
2
+ declared resync spans included (parsanol >= 0.18.19)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import pytest
7
+
8
+ from parsanol import PargArtifact
9
+
10
+
11
+ @pytest.fixture()
12
+ def artifact() -> PargArtifact:
13
+ path = _corpus() / "recovery_resync_declared.json"
14
+ if not path.is_file():
15
+ pytest.skip("corpus not present")
16
+ return PargArtifact.from_path(path)
17
+
18
+
19
+ def _corpus():
20
+ import os
21
+ from pathlib import Path
22
+
23
+ return Path(
24
+ os.environ.get("PARSANOL_CORPUS_DIR", "")
25
+ or Path(__file__).resolve().parents[1] / "corpus" / "conformance"
26
+ )
27
+
28
+
29
+ def test_recovery_supported(artifact: PargArtifact) -> None:
30
+ if not artifact.recovery_supported():
31
+ pytest.skip("loaded libparsanol predates the recovery export (0.18.19)")
32
+
33
+
34
+ def _require_recovery(artifact: PargArtifact) -> None:
35
+ if not artifact.recovery_supported():
36
+ pytest.skip("loaded libparsanol predates the recovery export (0.18.19)")
37
+
38
+
39
+ def test_failure_envelope_with_declared_resync(artifact: PargArtifact) -> None:
40
+ _require_recovery(artifact)
41
+ env = artifact.parse_recovering("a=1;b=2;@@junk@@;c=3;d=4;", "main")
42
+ assert env["ok"] is False
43
+ items = env["partial"]["document"]
44
+ assert len(items) == 4, f"expected all four pairs, got {len(items)}"
45
+ third = env["partial"]["document"][2]
46
+ assert third["k"]["value"] == "c" and third["v"]["value"] == "3"
47
+ assert env["error"]["offset"] == 8
48
+ assert len(env["resyncs"]) == 1
49
+ span = env["resyncs"][0]
50
+ assert span["skipped"] == "@@junk@@;"
51
+ assert span["offset"] == 8
52
+ assert span["resumed_at"] == 17
53
+
54
+
55
+ def test_success_envelope_is_the_shape(artifact: PargArtifact) -> None:
56
+ _require_recovery(artifact)
57
+ env = artifact.parse_recovering("a=1;b=2;", "main")
58
+ assert env["ok"] is True
59
+ assert env["shape"] == artifact.parse("a=1;b=2;", "main")
60
+
61
+
62
+ def test_null_entry_selects_the_default(artifact: PargArtifact) -> None:
63
+ _require_recovery(artifact)
64
+ from parsanol import _ffi
65
+
66
+ env = _ffi.parse_recovering(artifact.envelope_text, None, "a=1;b=2;")
67
+ assert env["ok"] is True