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.
- parsanol-0.1.0/PKG-INFO +59 -0
- parsanol-0.1.0/README.md +46 -0
- parsanol-0.1.0/pyproject.toml +32 -0
- parsanol-0.1.0/setup.cfg +4 -0
- parsanol-0.1.0/src/parsanol/__init__.py +31 -0
- parsanol-0.1.0/src/parsanol/_ffi.py +166 -0
- parsanol-0.1.0/src/parsanol/artifact.py +217 -0
- parsanol-0.1.0/src/parsanol/bindings.py +259 -0
- parsanol-0.1.0/src/parsanol/canonical.py +136 -0
- parsanol-0.1.0/src/parsanol.egg-info/PKG-INFO +59 -0
- parsanol-0.1.0/src/parsanol.egg-info/SOURCES.txt +14 -0
- parsanol-0.1.0/src/parsanol.egg-info/dependency_links.txt +1 -0
- parsanol-0.1.0/src/parsanol.egg-info/top_level.txt +1 -0
- parsanol-0.1.0/tests/test_binding.py +93 -0
- parsanol-0.1.0/tests/test_corpus.py +54 -0
- parsanol-0.1.0/tests/test_recovery.py +67 -0
parsanol-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
parsanol-0.1.0/README.md
ADDED
|
@@ -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"]
|
parsanol-0.1.0/setup.cfg
ADDED
|
@@ -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
|
+
|
|
@@ -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
|