yaffle 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yaffle/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """yaffle: the runtime of the Python code the yaffle compiler generates.
2
+
3
+ Generated modules import it as ``_rt``. Pure Python 3.12+, no dependencies. Besides what
4
+ generated code calls, it offers sources (:func:`file_source`), errors (:class:`YaffleError`),
5
+ checksums and canonical JSON helpers.
6
+
7
+ Modules: ``errors``, ``prim`` (primitive encodings), ``ints`` (number semantics), ``strings``,
8
+ ``checksum``, ``arrays`` (array builtins and equality), ``jsonc`` (canonical JSON), ``values``
9
+ (checks of values), ``externs`` (host code), ``codec`` (``@via`` and codec reuse), ``streams``
10
+ (streams), ``reader``, ``writer`` and ``layout`` (parse and serialize), ``api`` (root API
11
+ glue and sources), and for views ``store`` (bytes read on demand), ``view``, ``commit`` and
12
+ ``async_view``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from . import (
18
+ api,
19
+ arrays,
20
+ async_view,
21
+ checksum,
22
+ codec,
23
+ commit,
24
+ errors,
25
+ externs,
26
+ ints,
27
+ jsonc,
28
+ layout,
29
+ prim,
30
+ reader,
31
+ streams,
32
+ strings,
33
+ values,
34
+ view,
35
+ writer,
36
+ )
37
+ from .api import *
38
+ from .arrays import *
39
+ from .async_view import *
40
+ from .checksum import *
41
+ from .codec import *
42
+ from .commit import *
43
+ from .errors import *
44
+ from .externs import *
45
+ from .ints import *
46
+ from .jsonc import *
47
+ from .layout import *
48
+ from .prim import *
49
+ from .reader import *
50
+ from .streams import *
51
+ from .strings import *
52
+ from .values import *
53
+ from .view import *
54
+ from .writer import *
55
+
56
+ __version__ = "0.0.1"
57
+
58
+ __all__ = [
59
+ *api.__all__,
60
+ *arrays.__all__,
61
+ *async_view.__all__,
62
+ *checksum.__all__,
63
+ *codec.__all__,
64
+ *commit.__all__,
65
+ *errors.__all__,
66
+ *externs.__all__,
67
+ *ints.__all__,
68
+ *jsonc.__all__,
69
+ *layout.__all__,
70
+ *prim.__all__,
71
+ *reader.__all__,
72
+ *streams.__all__,
73
+ *strings.__all__,
74
+ *values.__all__,
75
+ *view.__all__,
76
+ *writer.__all__,
77
+ ]
yaffle/_harness.py ADDED
@@ -0,0 +1,288 @@
1
+ """Conformance harness: runs a batch of conformance cases through generated code.
2
+
3
+ Usage: ``python -m yaffle._harness <spec.json> <results-dir>``
4
+
5
+ The spec (written by the Python backend's conformance adapter) lists the cases of one case
6
+ directory: which generated module and class to use, the input bytes (hex or a file), the JSON,
7
+ and what is expected. One result file per case is written to the results directory
8
+ (``<index>.json``), with the actual canonical JSON, output bytes and error, and the harness's own
9
+ verdict (the runner re-checks them). See docs/CONFORMANCE.md.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import importlib
16
+ import json
17
+ import math
18
+ import os
19
+ import re
20
+ import sys
21
+ import traceback
22
+ from typing import Any
23
+
24
+ from .api import file_source
25
+ from .commit import apply_patches
26
+ from .errors import YaffleError
27
+
28
+ _NOT_IMPLEMENTED = re.compile(r"not implemented|not supported", re.IGNORECASE)
29
+
30
+
31
+ class _Failed(Exception):
32
+ """Ends a case with a message (not an error the case could expect)."""
33
+
34
+
35
+ def _type_name(v: Any) -> str:
36
+ if v is None:
37
+ return "null"
38
+ if isinstance(v, bool):
39
+ return "boolean"
40
+ if isinstance(v, (int, float)):
41
+ return "number"
42
+ if isinstance(v, str):
43
+ return "string"
44
+ if isinstance(v, list):
45
+ return "array"
46
+ return "object"
47
+
48
+
49
+ def _show(v: Any) -> str:
50
+ s = json.dumps(v)
51
+ return s if len(s) <= 120 else s[:120] + "…"
52
+
53
+
54
+ def json_diff(actual: Any, expected: Any, path: str = "") -> str | None:
55
+ """The first difference between two canonical JSON values (key order significant), or None."""
56
+ where = path or "/"
57
+ ta, te = _type_name(actual), _type_name(expected)
58
+ if ta != te:
59
+ return f"{where}: expected {_show(expected)}, got {_show(actual)}"
60
+ if ta == "array":
61
+ if len(actual) != len(expected):
62
+ return f"{where}: expected {len(expected)} elements, got {len(actual)}"
63
+ for i, (a, e) in enumerate(zip(actual, expected)):
64
+ d = json_diff(a, e, f"{path}/{i}")
65
+ if d:
66
+ return d
67
+ return None
68
+ if ta == "object":
69
+ for k in expected:
70
+ if k not in actual:
71
+ return f"{path}/{k}: missing"
72
+ for k in actual:
73
+ if k not in expected:
74
+ return f"{path}/{k}: unexpected"
75
+ for k in expected:
76
+ d = json_diff(actual[k], expected[k], f"{path}/{k}")
77
+ if d:
78
+ return d
79
+ if list(actual) != list(expected):
80
+ return f"{where}: fields are not in declaration order"
81
+ return None
82
+ if ta == "number":
83
+ a, e = float(actual), float(expected)
84
+ if a != e or (a == 0 and math.copysign(1.0, a) != math.copysign(1.0, e)):
85
+ return f"{where}: expected {_show(expected)}, got {_show(actual)}"
86
+ return None
87
+ if actual != expected:
88
+ return f"{where}: expected {_show(expected)}, got {_show(actual)}"
89
+ return None
90
+
91
+
92
+ def _load_bytes(spec: Any) -> bytes | None:
93
+ if spec is None:
94
+ return None
95
+ if isinstance(spec, str):
96
+ return bytes.fromhex(re.sub(r"[\s_]+", "", spec))
97
+ with open(spec["file"], "rb") as f:
98
+ return f.read()
99
+
100
+
101
+ def _options(cls: Any, c: dict[str, Any]) -> dict[str, Any]:
102
+ options: dict[str, Any] = cls.options_from_json(c.get("args") or {})
103
+ if c.get("strict"):
104
+ options["strict"] = True
105
+ return options
106
+
107
+
108
+ class _Case:
109
+ """One case: its spec, its generated class and the result being built."""
110
+
111
+ def __init__(self, c: dict[str, Any], cls: Any, max_hex: int) -> None:
112
+ self.c = c
113
+ self.cls = cls
114
+ self.max_hex = max_hex
115
+ self.is_async = bool(c.get("async"))
116
+ self.error: Any = c.get("error")
117
+ self.result: dict[str, Any] = {"name": c["name"], "ok": False}
118
+ self.phase = "serialize" if c.get("input") is not None else "parse"
119
+
120
+ def set_bytes(self, out: bytes) -> None:
121
+ if len(out) <= self.max_hex:
122
+ self.result["bytes"] = bytes(out).hex()
123
+
124
+ def expect_no_error(self, phase: str | None, what: str) -> None:
125
+ """Fails the case if it expects an error (in ``phase``, if given): ``what`` succeeded."""
126
+ if self.error and (phase is None or self.error.get("phase") == phase):
127
+ raise _Failed(f"expected {self.error['code']}, but {what} succeeded")
128
+
129
+ def check_json(self, actual: Any, mode: str, what: str) -> None:
130
+ """Records the JSON read; fails if a parse error was expected or the JSON differs."""
131
+ self.result["json"] = actual
132
+ self.expect_no_error("parse", what)
133
+ expected = self.c.get("json")
134
+ if expected is not None:
135
+ d = json_diff(actual, expected)
136
+ if d:
137
+ raise _Failed(f"{mode}: {d}")
138
+
139
+ async def parse(self, data: bytes, options: dict[str, Any]) -> Any:
140
+ if self.is_async:
141
+ return await self.cls.parse_async(data, **options)
142
+ return self.cls.parse(data, **options)
143
+
144
+ async def serialize(self, value: Any, options: dict[str, Any]) -> bytes:
145
+ write_options = {k: v for k, v in options.items() if k != "strict"}
146
+ if self.is_async:
147
+ out: bytes = await self.cls.serialize_async(value, **write_options)
148
+ else:
149
+ out = self.cls.serialize(value, **write_options)
150
+ self.set_bytes(out)
151
+ return out
152
+
153
+ async def run(self) -> None:
154
+ c = self.c
155
+ options = _options(self.cls, c)
156
+ data = _load_bytes(c.get("bytes"))
157
+ if c.get("input") is not None:
158
+ out = await self.serialize(self.cls.from_json(c["input"]), options)
159
+ self.expect_no_error(None, "serialize")
160
+ if data is None:
161
+ raise _Failed("serialize case without bytes")
162
+ if out != data:
163
+ raise _Failed(f"serialize: expected {data.hex()}, got {self.result.get('bytes', '(large)')}")
164
+ return
165
+ if data is None:
166
+ raise _Failed("parse case without bytes")
167
+ value = await self.parse(data, options)
168
+ self.check_json(value.to_json(), "parse", "parse")
169
+ # Cases expecting a serialize error round-trip the JSON.
170
+ rt = "json" if (self.error and self.error.get("phase") == "serialize") else c.get("roundtrip", True)
171
+ if rt is False:
172
+ return
173
+ self.phase = "serialize"
174
+ source = value if rt == "value" else self.cls.from_json(c["json"] if c.get("json") is not None else self.result["json"])
175
+ out = await self.serialize(source, options)
176
+ self.expect_no_error("serialize", "serialize")
177
+ if out != data:
178
+ i = 0
179
+ n = min(len(out), len(data))
180
+ while i < n and out[i] == data[i]:
181
+ i += 1
182
+ raise _Failed(f"roundtrip: bytes differ at offset 0x{i:x} (expected {len(data)} bytes, got {len(out)})")
183
+
184
+ async def run_view(self) -> None:
185
+ """View mode (docs/CONFORMANCE.md §7): read every field through the lazy view, then commit
186
+ with no edits (which must reproduce the input)."""
187
+ c = self.c
188
+ options = _options(self.cls, c)
189
+ spec = c.get("bytes")
190
+ if spec is None:
191
+ raise _Failed("view case without bytes")
192
+ # A file is viewed through a memory-mapped source.
193
+ src: Any = _load_bytes(spec) if isinstance(spec, str) else file_source(spec["file"])
194
+ try:
195
+ if self.is_async:
196
+ v = self.cls.view_async(src, **options)
197
+ actual = await v._to_json()
198
+ else:
199
+ v = self.cls.view(src, **options)
200
+ actual = self.cls.to_json(v) # reads every field through the view
201
+ self.check_json(actual, "view", "reading the view")
202
+ if c.get("roundtrip", True) is False:
203
+ return
204
+ self.phase = "serialize"
205
+ orig = _load_bytes(spec)
206
+ assert orig is not None
207
+ if self.is_async:
208
+ out = apply_patches(orig, await v._commit())
209
+ else:
210
+ out = v._commit()
211
+ self.set_bytes(out)
212
+ if orig != bytes(out):
213
+ raise _Failed(f"commit with no edits changed the bytes ({len(orig)} → {len(out)} bytes)")
214
+ finally:
215
+ close = getattr(src, "close", None)
216
+ if callable(close):
217
+ close()
218
+
219
+ def failed(self, e: BaseException) -> None:
220
+ """Records that the case raised ``e`` in the current phase."""
221
+ result = self.result
222
+ if not isinstance(e, YaffleError):
223
+ msg = str(e)
224
+ if _NOT_IMPLEMENTED.search(msg):
225
+ result["skipped"] = msg
226
+ else:
227
+ result["message"] = f"unexpected exception: {type(e).__name__}: {msg}\n{traceback.format_exc()}"
228
+ return
229
+ message = e.describe()
230
+ err = {"phase": self.phase, "code": e.code, "path": list(e.path), "offset": e.offset, "message": message}
231
+ result["error"] = err
232
+ expected = self.error
233
+ if not expected:
234
+ if _NOT_IMPLEMENTED.search(message):
235
+ result["skipped"] = message
236
+ else:
237
+ result["message"] = f"unexpected {e.code} error in {self.phase}: {message}"
238
+ elif expected.get("phase") != self.phase:
239
+ result["message"] = f"expected a {expected['phase']} error, got {e.code} in {self.phase}: {message}"
240
+ elif e.code != expected.get("code"):
241
+ result["message"] = f"expected {expected['code']}, got {e.code}: {message}"
242
+ elif expected.get("path") is not None and [str(x) for x in e.path] != [str(x) for x in expected["path"]]:
243
+ result["message"] = f"expected path {expected['path']}, got {list(e.path)} ({message})"
244
+ else:
245
+ result["ok"] = True
246
+
247
+
248
+ async def run_case(c: dict[str, Any], max_hex: int, mode: str = "parse") -> dict[str, Any]:
249
+ try:
250
+ cls = getattr(importlib.import_module(c["module"]), c["cls"])
251
+ except Exception as e: # pragma: no cover - generated code that doesn't import
252
+ return {"name": c["name"], "ok": False, "message": f"cannot import generated code: {e}\n{traceback.format_exc()}"}
253
+ case = _Case(c, cls, max_hex)
254
+ try:
255
+ await (case.run_view() if mode == "view" else case.run())
256
+ case.result["ok"] = True
257
+ except _Failed as f:
258
+ case.result["message"] = str(f)
259
+ except (KeyboardInterrupt, SystemExit):
260
+ raise
261
+ except BaseException as e: # noqa: BLE001 - every failure is reported
262
+ case.failed(e)
263
+ return case.result
264
+
265
+
266
+ async def main(spec_path: str, out_dir: str) -> None:
267
+ with open(spec_path, encoding="utf-8") as f:
268
+ spec = json.load(f)
269
+ # Real files nest deeply.
270
+ sys.setrecursionlimit(max(sys.getrecursionlimit(), 200_000))
271
+ os.makedirs(out_dir, exist_ok=True)
272
+ max_hex = int(spec.get("maxHexBytes", 1 << 20))
273
+ mode = str(spec.get("mode", "parse"))
274
+ for i, c in enumerate(spec["cases"]):
275
+ res = await run_case(c, max_hex, mode)
276
+ tmp = os.path.join(out_dir, f"{i}.json.tmp")
277
+ with open(tmp, "w", encoding="utf-8") as f:
278
+ try:
279
+ json.dump(res, f, allow_nan=False)
280
+ except (TypeError, ValueError) as e:
281
+ f.seek(0)
282
+ f.truncate()
283
+ json.dump({"name": c["name"], "ok": False, "message": f"result is not JSON: {e}"}, f)
284
+ os.replace(tmp, os.path.join(out_dir, f"{i}.json"))
285
+
286
+
287
+ if __name__ == "__main__":
288
+ asyncio.run(main(sys.argv[1], sys.argv[2]))
yaffle/api.py ADDED
@@ -0,0 +1,212 @@
1
+ """What the generated root APIs (``X.parse``, ``X.serialize``, …) run on, and byte sources.
2
+
3
+ A source is a bytes-like object, or an object with a ``size`` and ``read(offset, length)``. If
4
+ ``read`` returns an awaitable, the source is async: use ``parse_async`` (or an async view).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ import mmap
11
+ import os
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from typing import Any, Awaitable, Callable, Generic, Literal, Protocol, Sequence, TypeVar, Union
15
+
16
+ from .errors import YaffleError, fail
17
+ from .reader import Reader
18
+ from .writer import Region, Writer
19
+
20
+ __all__ = [
21
+ "AsyncSource", "FileSource", "SafeResult", "Source", "SyncSource", "endian_option",
22
+ "file_source", "parse_with", "parse_with_async", "read_all", "read_all_async", "safe",
23
+ "safe_async", "serialize_with", "serialize_with_async",
24
+ ] # fmt: skip
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ class SyncSource(Protocol):
30
+ @property
31
+ def size(self) -> int: ...
32
+
33
+ def read(self, offset: int, length: int) -> bytes: ...
34
+
35
+
36
+ class AsyncSource(Protocol):
37
+ @property
38
+ def size(self) -> int: ...
39
+
40
+ def read(self, offset: int, length: int) -> Awaitable[bytes]: ...
41
+
42
+
43
+ Source = Union[bytes, bytearray, memoryview, SyncSource, AsyncSource]
44
+
45
+
46
+ @dataclass
47
+ class SafeResult(Generic[T]):
48
+ """The result of ``safe_parse``: ``ok`` with a ``value``, or not ``ok`` with an ``error``."""
49
+
50
+ ok: bool
51
+ value: T | None = None
52
+ error: YaffleError | None = None
53
+
54
+
55
+ def ensure_recursion(limit: int = 100_000) -> None:
56
+ """Raises the interpreter's recursion limit for deeply nested data (never lowers it)."""
57
+ if sys.getrecursionlimit() < limit:
58
+ sys.setrecursionlimit(limit)
59
+
60
+
61
+ def reject_path(source: Any) -> None:
62
+ if isinstance(source, (str, os.PathLike)):
63
+ fail("INPUT", "pass bytes or a source, not a path (use file_source(path))")
64
+
65
+
66
+ def read_all(source: Any) -> bytes:
67
+ """Reads a whole sync source into memory."""
68
+ if isinstance(source, (bytes, bytearray, memoryview)):
69
+ return bytes(source)
70
+ reject_path(source)
71
+ out = source.read(0, source.size)
72
+ if inspect.isawaitable(out):
73
+ if inspect.iscoroutine(out):
74
+ out.close()
75
+ fail("INPUT", "this source is async; use parse_async or an async view")
76
+ return bytes(out)
77
+
78
+
79
+ async def read_all_async(source: Any) -> bytes:
80
+ """Reads a whole source (sync or async) into memory."""
81
+ if isinstance(source, (bytes, bytearray, memoryview)):
82
+ return bytes(source)
83
+ out = source.read(0, source.size)
84
+ if inspect.isawaitable(out):
85
+ out = await out
86
+ return bytes(out)
87
+
88
+
89
+ def parse_with(source: Any, strict: bool, read: Callable[[Reader], T]) -> T:
90
+ """Runs a generated root reader over a sync source."""
91
+ ensure_recursion()
92
+ return read(Reader(read_all(source), strict))
93
+
94
+
95
+ async def parse_with_async(source: Any, strict: bool, read: Callable[[Reader], Any]) -> Any:
96
+ """Runs a generated root reader (sync or async) over any source."""
97
+ ensure_recursion()
98
+ out = read(Reader(await read_all_async(source), strict))
99
+ return await out if inspect.isawaitable(out) else out
100
+
101
+
102
+ def serialize_with(layout: Sequence[Any] | None, write: Callable[[Writer], Any]) -> bytes:
103
+ """Runs a generated root writer in a fresh root region and returns the bytes."""
104
+ ensure_recursion()
105
+ region = Region(layout)
106
+ region.attach_root(write(Writer(region)))
107
+ return bytes(region.finish())
108
+
109
+
110
+ async def serialize_with_async(
111
+ layout: Sequence[Any] | None, write: Callable[[Writer], Awaitable[Any]]
112
+ ) -> bytes:
113
+ ensure_recursion()
114
+ region = Region(layout)
115
+ region.attach_root(await write(Writer(region)))
116
+ return bytes(region.finish())
117
+
118
+
119
+ def safe(fn: Callable[[], T]) -> SafeResult[T]:
120
+ """Runs ``fn``; a raised :class:`YaffleError` becomes ``SafeResult(ok=False, error=…)``."""
121
+ try:
122
+ return SafeResult(True, fn())
123
+ except YaffleError as e:
124
+ return SafeResult(False, None, e)
125
+
126
+
127
+ async def safe_async(fn: Callable[[], Awaitable[T]]) -> SafeResult[T]:
128
+ try:
129
+ return SafeResult(True, await fn())
130
+ except YaffleError as e:
131
+ return SafeResult(False, None, e)
132
+
133
+
134
+ def endian_option(v: Any) -> Literal["le", "be"]:
135
+ if v == "le":
136
+ return "le"
137
+ if v == "be":
138
+ return "be"
139
+ fail("INPUT", f'endian must be "le" or "be", got {v!r}')
140
+
141
+
142
+ class FileSource:
143
+ """A sync random-access source over a file (``size``, ``read(offset, length)``).
144
+
145
+ ``buffer()`` memory-maps the file, so views read only the pages they touch. With
146
+ ``writable=True`` the source also has ``write`` and ``truncate``, and a view's ``_commit``
147
+ writes the edits back to the file.
148
+ """
149
+
150
+ def __init__(self, path: str | os.PathLike[str], writable: bool = False) -> None:
151
+ self._f = open(path, "r+b" if writable else "rb") # noqa: SIM115
152
+ self._f.seek(0, os.SEEK_END)
153
+ self.size = self._f.tell()
154
+ self.writable = writable
155
+ self._map: mmap.mmap | None = None
156
+ if not writable:
157
+ # Views write commits back to sources that have `write`.
158
+ self.write = None # type: ignore[assignment, method-assign]
159
+
160
+ def read(self, offset: int, length: int) -> bytes:
161
+ self._f.seek(offset)
162
+ data = self._f.read(length)
163
+ if len(data) != length:
164
+ fail("EOF", "short read from file", offset)
165
+ return data
166
+
167
+ def buffer(self) -> Any:
168
+ """The file's bytes as a read-only memory map (bytes for an empty file)."""
169
+ if self.size == 0:
170
+ return b""
171
+ if self._map is None:
172
+ self._map = mmap.mmap(self._f.fileno(), 0, access=mmap.ACCESS_READ)
173
+ return self._map
174
+
175
+ def copy_buffer(self) -> Any:
176
+ """A private copy-on-write map of the file: a view's in-place patches copy only the pages
177
+ they write, and the file is not changed."""
178
+ if self.size == 0:
179
+ return bytearray()
180
+ return mmap.mmap(self._f.fileno(), 0, access=mmap.ACCESS_COPY)
181
+
182
+ def write(self, offset: int, data: bytes) -> None:
183
+ self._drop_map()
184
+ self._f.seek(offset)
185
+ self._f.write(data)
186
+ self._f.flush()
187
+ self.size = max(self.size, offset + len(data))
188
+
189
+ def truncate(self, n: int) -> None:
190
+ self._drop_map()
191
+ self._f.truncate(n)
192
+ self.size = n
193
+
194
+ def _drop_map(self) -> None:
195
+ if self._map is not None:
196
+ self._map.close()
197
+ self._map = None
198
+
199
+ def close(self) -> None:
200
+ self._drop_map()
201
+ self._f.close()
202
+
203
+ def __enter__(self) -> FileSource:
204
+ return self
205
+
206
+ def __exit__(self, *exc: Any) -> None:
207
+ self.close()
208
+
209
+
210
+ def file_source(path: str | os.PathLike[str], writable: bool = False) -> FileSource:
211
+ """A source over a file (memory-mapped for views; ``writable`` for committing views back)."""
212
+ return FileSource(path, writable)