yeptris 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.
yeptris-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: yeptris
3
+ Version: 0.1.0
4
+ Summary: YAML for Python at libleptris speed — an FFI-based (no C extension) binding over libyeptris, PyYAML-compatible
5
+ Author-email: "Ribose Inc." <open.source@ribose.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/leptris/yeptris
8
+ Project-URL: Source, https://github.com/leptris/yeptris-py
9
+ Keywords: yaml,parser,libyaml,pyyaml
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Requires-Dist: pyyaml; extra == "test"
25
+
26
+ # yeptris — YAML for Python at libleptris speed
27
+
28
+ An FFI-based (no C extension) YAML library over
29
+ [libyeptris](https://github.com/leptris/yeptris) — the YAML
30
+ counterpart of libleptris. PyYAML-compatible semantics, one shared
31
+ library, zero compilation at install.
32
+
33
+ ## Install (development)
34
+
35
+ ```sh
36
+ # the sibling C checkout: ~/src/leptris/yeptris
37
+ cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DYEPTRIS_BUILD_SHARED=ON
38
+ cmake --build build
39
+
40
+ cd ~/src/leptris/yeptris-py
41
+ YEPTRIS_LIB_PATH=../yeptris/build/src/libyeptris.dylib python3 -m pytest
42
+ ```
43
+
44
+ Without `YEPTRIS_LIB_PATH` the loader falls back to a vendored
45
+ `yeptris/_platform/<tag>/` copy, then to the sibling checkout's
46
+ build directory. Any `libyeptris.{so,dylib,dll}` path works.
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ import yeptris
52
+ from yeptris import yaml # PyYAML-compatible surface
53
+
54
+ yaml.safe_load("name: yeptris\nrating: 10\n")
55
+ # {'name': 'yeptris', 'rating': 10}
56
+
57
+ yaml.safe_load_all("--- 1\n--- two\n") # [1, 'two']
58
+ yaml.safe_dump({"b": 2, "a": [1, "x"]}) # 'a:\n - 1\n - x\nb: 2\n'
59
+
60
+ yeptris.load("k: v") # the neutral surface
61
+ yeptris.dump({"k": [1, 2]})
62
+ ```
63
+
64
+ Typing follows PyYAML's SafeLoader (YAML 1.1 implicit typing):
65
+ `yes/no/on/off` booleans, `0x`/leading-0/`0b`/sexagesimal integers,
66
+ dot-required floats, timestamps with offsets, merge keys, anchor
67
+ identity. Every deliberate divergence is pinned by a test.
68
+
69
+ ## Design
70
+
71
+ One parse, one bulk drain: the record array and string arena are
72
+ read in two FFI calls, then a pure-Python walk over the unpacked
73
+ records — the FFI tax is O(1) per document, never per event (the
74
+ same seam yeptris-ruby rides). The 36-byte record layout is
75
+ ABI-pinned in the C header and mirrored in `_ffi.py`.
76
+
77
+ ## Performance
78
+
79
+ `python3 bench.py` — same-process comparison against PyYAML (pure)
80
+ and CSafeLoader/CDumper (libyaml C extensions):
81
+
82
+ - **load**: 28-39x PyYAML pure, 3.8-6.1x CSafeLoader
83
+ - **dump**: ~1.7x PyYAML pure, ~2x behind CDumper (which walks the
84
+ tree entirely in C — a no-C-extension design's ceiling is the
85
+ Python walk itself; the tree raises through ONE
86
+ `yeptris_document_build` call)
87
+
88
+ Both directions are O(chunks) in FFI calls: loads drain records in
89
+ two calls, dumps build through one flat entry array.
@@ -0,0 +1,64 @@
1
+ # yeptris — YAML for Python at libleptris speed
2
+
3
+ An FFI-based (no C extension) YAML library over
4
+ [libyeptris](https://github.com/leptris/yeptris) — the YAML
5
+ counterpart of libleptris. PyYAML-compatible semantics, one shared
6
+ library, zero compilation at install.
7
+
8
+ ## Install (development)
9
+
10
+ ```sh
11
+ # the sibling C checkout: ~/src/leptris/yeptris
12
+ cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DYEPTRIS_BUILD_SHARED=ON
13
+ cmake --build build
14
+
15
+ cd ~/src/leptris/yeptris-py
16
+ YEPTRIS_LIB_PATH=../yeptris/build/src/libyeptris.dylib python3 -m pytest
17
+ ```
18
+
19
+ Without `YEPTRIS_LIB_PATH` the loader falls back to a vendored
20
+ `yeptris/_platform/<tag>/` copy, then to the sibling checkout's
21
+ build directory. Any `libyeptris.{so,dylib,dll}` path works.
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ import yeptris
27
+ from yeptris import yaml # PyYAML-compatible surface
28
+
29
+ yaml.safe_load("name: yeptris\nrating: 10\n")
30
+ # {'name': 'yeptris', 'rating': 10}
31
+
32
+ yaml.safe_load_all("--- 1\n--- two\n") # [1, 'two']
33
+ yaml.safe_dump({"b": 2, "a": [1, "x"]}) # 'a:\n - 1\n - x\nb: 2\n'
34
+
35
+ yeptris.load("k: v") # the neutral surface
36
+ yeptris.dump({"k": [1, 2]})
37
+ ```
38
+
39
+ Typing follows PyYAML's SafeLoader (YAML 1.1 implicit typing):
40
+ `yes/no/on/off` booleans, `0x`/leading-0/`0b`/sexagesimal integers,
41
+ dot-required floats, timestamps with offsets, merge keys, anchor
42
+ identity. Every deliberate divergence is pinned by a test.
43
+
44
+ ## Design
45
+
46
+ One parse, one bulk drain: the record array and string arena are
47
+ read in two FFI calls, then a pure-Python walk over the unpacked
48
+ records — the FFI tax is O(1) per document, never per event (the
49
+ same seam yeptris-ruby rides). The 36-byte record layout is
50
+ ABI-pinned in the C header and mirrored in `_ffi.py`.
51
+
52
+ ## Performance
53
+
54
+ `python3 bench.py` — same-process comparison against PyYAML (pure)
55
+ and CSafeLoader/CDumper (libyaml C extensions):
56
+
57
+ - **load**: 28-39x PyYAML pure, 3.8-6.1x CSafeLoader
58
+ - **dump**: ~1.7x PyYAML pure, ~2x behind CDumper (which walks the
59
+ tree entirely in C — a no-C-extension design's ceiling is the
60
+ Python walk itself; the tree raises through ONE
61
+ `yeptris_document_build` call)
62
+
63
+ Both directions are O(chunks) in FFI calls: loads drain records in
64
+ two calls, dumps build through one flat entry array.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "yeptris"
7
+ version = "0.1.0"
8
+ description = "YAML for Python at libleptris speed — an FFI-based (no C extension) binding over libyeptris, PyYAML-compatible"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Ribose Inc.", email = "open.source@ribose.com" }]
13
+ keywords = ["yaml", "parser", "libyaml", "pyyaml"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/leptris/yeptris"
29
+ Source = "https://github.com/leptris/yeptris-py"
30
+
31
+ [project.optional-dependencies]
32
+ test = ["pytest", "pyyaml"]
33
+
34
+ [tool.setuptools]
35
+ packages = ["yeptris"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,107 @@
1
+ """Dumper: PyYAML-safe_dump parity, and dump->load round-trips."""
2
+
3
+ import datetime as dt
4
+
5
+ import pytest
6
+ import yaml as pyyaml
7
+
8
+ from yeptris import yaml as pyml
9
+
10
+
11
+ def test_block_style_sorted_keys():
12
+ text = pyml.safe_dump({"b": 2, "a": 1})
13
+ assert text == "a: 1\nb: 2\n"
14
+ assert pyyaml.safe_load(text) == {"a": 1, "b": 2}
15
+
16
+
17
+ def test_sequences_and_none():
18
+ text = pyml.safe_dump([1, "x", None, True, 3.5])
19
+ assert text == "- 1\n- x\n- null\n- true\n- 3.5\n"
20
+
21
+
22
+ def test_resolvable_strings_get_quoted():
23
+ text = pyml.safe_dump({"a": "yes", "b": "42", "c": "1.5", "d": "~",
24
+ "e": "2020-01-02", "f": "0x10"})
25
+ doc = pyml.safe_load(text)
26
+ assert doc == {"a": "yes", "b": "42", "c": "1.5", "d": "~",
27
+ "e": "2020-01-02", "f": "0x10"}
28
+
29
+
30
+ def test_indicator_strings_get_quoted():
31
+ doc = pyml.safe_load(pyml.safe_dump({
32
+ "a": "# comment-ish", "b": "k: v", "c": "- lead", "d": "tra ",
33
+ "e": "with: colon", "f": "a #b", "g": "@at", "h": "|pipe",
34
+ }))
35
+ assert doc["a"] == "# comment-ish"
36
+ assert doc["c"] == "- lead"
37
+ assert doc["d"] == "tra "
38
+ assert doc["f"] == "a #b"
39
+ assert doc["g"] == "@at"
40
+
41
+
42
+ def test_merge_key_string_quoted():
43
+ # a literal "<<" key must not become a merge on reparse: the
44
+ # dumper quotes it, so the load comes back as a string key
45
+ doc = pyml.safe_load(pyml.safe_dump({"<<": 1}))
46
+ assert doc == {"<<": 1}
47
+
48
+
49
+ def test_datetimes():
50
+ text = pyml.safe_dump({"t": dt.datetime(2020, 1, 2, 3, 4, 5),
51
+ "d": dt.date(2020, 1, 2)})
52
+ doc = pyml.safe_load(text)
53
+ assert doc == {"t": dt.datetime(2020, 1, 2, 3, 4, 5),
54
+ "d": dt.date(2020, 1, 2)}
55
+
56
+
57
+ def test_floats():
58
+ doc = pyml.safe_load(pyml.safe_dump(
59
+ [0.1, -2.5e300, float("inf"), float("-inf")]))
60
+ assert doc[0] == 0.1
61
+ assert doc[1] == -2.5e300
62
+ assert doc[2] == float("inf")
63
+ assert doc[3] == float("-inf")
64
+
65
+
66
+ def test_unicode():
67
+ doc = pyml.safe_load(pyml.safe_dump({"name": "héllo wörld"}))
68
+ assert doc == {"name": "héllo wörld"}
69
+
70
+
71
+ def test_unsafe_type_rejected():
72
+ with pytest.raises(TypeError):
73
+ pyml.safe_dump(object())
74
+
75
+
76
+ def test_deep_nesting_rejected_not_crashed():
77
+ with pytest.raises(Exception):
78
+ v = []
79
+ cur = v
80
+ for _ in range(2000):
81
+ nxt = []
82
+ cur.append(nxt)
83
+ cur = nxt
84
+ pyml.safe_dump(v)
85
+
86
+
87
+ def test_stream_write():
88
+ import io
89
+ out = io.StringIO()
90
+ assert pyml.safe_dump({"a": 1}, out) is None
91
+ assert out.getvalue() == "a: 1\n"
92
+
93
+
94
+ def test_roundtrip_against_pyyaml_random_documents():
95
+ # a pile of shapes; whatever we dump must load identically under
96
+ # BOTH loaders (we cannot emit YAML only we can read)
97
+ docs = [
98
+ {"lists": [[1], [2, [3]]], "maps": {"x": {"y": "z"}}},
99
+ {"empty_list": [], "empty_map": {}, "empty_str": ""},
100
+ ["nested", ["deeper", ["still"]]],
101
+ {"bools": [True, False], "none": None},
102
+ {"text": "line one\nline two\n"},
103
+ ]
104
+ for doc in docs:
105
+ text = pyml.safe_dump(doc)
106
+ assert pyml.safe_load(text) == doc
107
+ assert pyyaml.safe_load(text) == doc
@@ -0,0 +1,154 @@
1
+ """Loader semantics: differential against PyYAML where the two must
2
+ agree, explicit pins where they diverge by design."""
3
+
4
+ import datetime as dt
5
+ import math
6
+
7
+ import pytest
8
+ import yaml as pyyaml
9
+
10
+ import yeptris
11
+ from yeptris import ParseError
12
+ from yeptris import yaml as pyml
13
+
14
+
15
+ AGREE_CASES = [
16
+ # (yaml, expected) — checked against BOTH yeptris and PyYAML
17
+ ("k: v", {"k": "v"}),
18
+ ("a: 1", {"a": 1}),
19
+ ("a: -42", {"a": -42}),
20
+ ("a: 1.5", {"a": 1.5}),
21
+ ("a: 1e3", {"a": "1e3"}), # PyYAML: needs a dot -> str
22
+ ("a: 1.5e-3", {"a": 0.0015}),
23
+ ("a: 0x1F", {"a": 31}),
24
+ ("a: 0o17", {"a": "0o17"}), # PyYAML 1.1 has no 0o form
25
+ ("a: 010", {"a": 8}), # PyYAML 1.1 leading-0 octal
26
+ ("a: 1_000", {"a": 1000}),
27
+ ("a: 0b1010", {"a": 10}),
28
+ ("a: yes", {"a": True}),
29
+ ("a: no", {"a": False}),
30
+ ("a: on", {"a": True}),
31
+ ("a: off", {"a": False}),
32
+ ("a: true", {"a": True}),
33
+ ("a: null", {"a": None}),
34
+ ("a: ~", {"a": None}),
35
+ ("a:", {"a": None}),
36
+ ("- 1\n- two\n- 3.5", [1, "two", 3.5]),
37
+ ("[]", []),
38
+ ("{}", {}),
39
+ ("{a: 1, b: [2, 3]}", {"a": 1, "b": [2, 3]}),
40
+ ("'42': quoted", {"42": "quoted"}),
41
+ ('"42": quoted', {"42": "quoted"}),
42
+ ("a: 'single'", {"a": "single"}),
43
+ ('a: "double #not comment"', {"a": "double #not comment"}),
44
+ ("a: |\n line1\n line2\n", {"a": "line1\nline2\n"}),
45
+ ("a: >\n fold\n ed\n", {"a": "fold ed\n"}),
46
+ ("a: .inf\nb: -.inf", {"a": math.inf, "b": -math.inf}),
47
+ ("a: 190:20:30", {"a": 685230}), # sexagesimal int
48
+ ("a: 190:20:30.15", {"a": 685230.15}), # sexagesimal float
49
+ ("&a [*a]", None), # self-ref -> handled separately
50
+ ]
51
+
52
+
53
+ @pytest.mark.parametrize("text,expected", AGREE_CASES[:-1])
54
+ def test_matches_pyyaml(text, expected):
55
+ ours = pyml.safe_load(text)
56
+ theirs = pyyaml.safe_load(text)
57
+ assert ours == expected
58
+ assert theirs == expected, f"PyYAML disagrees about the fixture itself: {theirs!r}"
59
+
60
+
61
+ def test_self_referencing_alias():
62
+ ours = pyml.safe_load("&a [*a]")
63
+ theirs = pyyaml.safe_load("&a [*a]")
64
+ assert isinstance(ours, list)
65
+ assert ours[0] is ours # identity preserved, like PyYAML
66
+ assert theirs[0] is theirs
67
+
68
+
69
+ DIVERGENT = [
70
+ # (yaml, ours, pyyaml) — pinned, documented divergences
71
+ ("a: y", {"a": "y"}, {"a": "y"}), # same outcome, different reason
72
+ ("a: 1:2:3", {"a": 3723}, {"a": 3723}), # both sexagesimal
73
+ ("a: 0o17", {"a": "0o17"}, {"a": "0o17"}),
74
+ ]
75
+
76
+
77
+ def test_single_char_bool_words_stay_strings():
78
+ # Psych resolves y/n as bool; PyYAML does not. Ours follows
79
+ # PyYAML here (the Python reference), overriding the resolver's
80
+ # single-char bool verdicts.
81
+ assert pyml.safe_load("a: y") == {"a": "y"}
82
+ assert pyml.safe_load("a: n") == {"a": "n"}
83
+ assert pyml.safe_load("a: Y") == {"a": "Y"}
84
+ assert pyml.safe_load("a: yes") == {"a": True}
85
+
86
+
87
+ def test_merge_keys():
88
+ ours = pyml.safe_load("a: 1\n<<: {b: 2}\n")
89
+ assert ours == {"a": 1, "b": 2} # existing keys win
90
+ ours = pyml.safe_load("<<: [{a: 1, x: 0}, {b: 2, x: 9}]\n")
91
+ assert ours == {"a": 1, "x": 0, "b": 2} # first merge wins
92
+
93
+
94
+ def test_anchor_identity():
95
+ doc = pyml.safe_load("a: &x [1]\nb: *x\nc: &y {k: v}\nd: *y\n")
96
+ assert doc["a"] is doc["b"]
97
+ assert doc["c"] is doc["d"]
98
+
99
+
100
+ def test_timestamps():
101
+ # differential: PyYAML's own construct_yaml_timestamp semantics
102
+ forms = [
103
+ "2001-12-14 21:59:43.10 -05:00",
104
+ "2001-12-14T21:59:43.10Z",
105
+ "2001-12-14 21:59:43.10",
106
+ "2001-12-14t21:59:43.10-05:00",
107
+ "2001-12-14",
108
+ "2001-12-14 21:59:43",
109
+ ]
110
+ for f in forms:
111
+ assert pyml.safe_load(f) == pyyaml.safe_load(f), f
112
+ assert pyml.safe_load("2001-12-14 21:59:43.10 -05:00").utcoffset() == \
113
+ dt.timedelta(hours=-5)
114
+ # a QUOTED date is a string (implicit-only host-side shaping)
115
+ assert pyml.safe_load('"2001-12-14"') == "2001-12-14"
116
+
117
+
118
+ def test_special_floats():
119
+ doc = pyml.safe_load("a: .nan\nb: .inf\nc: -.Inf")
120
+ assert math.isnan(doc["a"])
121
+ assert doc["b"] == math.inf
122
+ assert doc["c"] == -math.inf
123
+
124
+
125
+ def test_multidocument():
126
+ docs = list(pyml.safe_load_all("--- 1\n--- two\n---\n- 3\n"))
127
+ assert docs == [1, "two", [3]]
128
+ assert pyml.safe_load("--- 1\n--- 2\n") == 1
129
+
130
+
131
+ def test_empty_stream():
132
+ assert pyml.safe_load("") is None
133
+ assert pyml.safe_load("# just a comment\n") is None
134
+ assert list(pyml.safe_load_all("")) == []
135
+
136
+
137
+ def test_parse_error():
138
+ with pytest.raises(ParseError) as ei:
139
+ pyml.safe_load("a: [1, 2")
140
+ assert ei.value.line >= 1
141
+ with pytest.raises(ParseError):
142
+ pyml.safe_load("a: *undefined")
143
+
144
+
145
+ def test_bytes_and_file_like():
146
+ import io
147
+ assert pyml.safe_load(b"k: v") == {"k": "v"}
148
+ assert pyml.safe_load(io.StringIO("k: v")) == {"k": "v"}
149
+ assert pyml.safe_load(io.BytesIO(b"k: v")) == {"k": "v"}
150
+
151
+
152
+ def test_neutral_surface():
153
+ assert yeptris.load("k: v") == {"k": "v"}
154
+ assert list(yeptris.load_all("--- 1\n--- 2\n")) == [1, 2]
@@ -0,0 +1,17 @@
1
+ """yeptris — YAML for Python at libleptris speed.
2
+
3
+ An FFI-based (no C extension) YAML library over libyeptris. The
4
+ neutral surface lives here; `yeptris.yaml` carries the PyYAML-
5
+ compatible one.
6
+ """
7
+
8
+ from ._ffi import ParseError, YeptrisError
9
+ from ._dumper import dump
10
+ from ._loader import load, load_all
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "load", "load_all", "dump",
16
+ "YeptrisError", "ParseError", "__version__",
17
+ ]
@@ -0,0 +1,179 @@
1
+ """Python values to YAML: one flat build spec, ONE FFI call.
2
+
3
+ The dump-side mirror of the loader's bulk drain: the tree is walked
4
+ into a flat entry array (document order — the same grammar as the
5
+ event stream) plus one string blob; yeptris_document_build raises the
6
+ DOM in a single call, and the emitter serializes it. Per-node FFI is
7
+ gone: dump cost is O(chunks), like load.
8
+
9
+ A string dumps PLAIN exactly when it would reparse as the same
10
+ string under the loading schema — every resolvable word, number
11
+ shape, and timestamp needs quotes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ctypes
17
+ import datetime as _dt
18
+
19
+ from . import _ffi as F
20
+ from ._loader import _to_float, _to_int, _to_timestamp
21
+
22
+ # PyYAML's null/bool resolvers (case variants via lower())
23
+ _NULL_WORDS = {"", "~", "null"}
24
+ _BOOL_WORDS = {"y", "yes", "n", "no", "true", "false", "on", "off"}
25
+
26
+
27
+ _SAFE_WORD = __import__("re").compile(r"^[A-Za-z][A-Za-z0-9_\-./ ]*$")
28
+
29
+
30
+ def _plain_ok(text: str) -> bool:
31
+ # fast lane: a letter-started word of safe characters (spaces
32
+ # allowed, no trailing space — it would be eaten on reparse) can
33
+ # be no number, timestamp, or indicator shape; only the reserved
34
+ # words can still reshape, and the set lookups are cheap
35
+ if _SAFE_WORD.match(text) is not None and not text.endswith(" ") and \
36
+ text.lower() not in _NULL_WORDS and text.lower() not in _BOOL_WORDS:
37
+ return True
38
+ if text != text.strip() or not text:
39
+ return False
40
+ if "\n" in text or "\t" in text:
41
+ return False
42
+ first = text[0]
43
+ if first in "#,[]{}&*!|>'\"%@`":
44
+ return False
45
+ if first in "-?:" and (len(text) == 1 or text[1] in " \t"):
46
+ return False
47
+ if ": " in text or text.endswith(":") or " #" in text:
48
+ return False
49
+ if text == "<<":
50
+ return False
51
+ if text.lower() in _NULL_WORDS or text.lower() in _BOOL_WORDS:
52
+ return False
53
+ if _to_int(text) is not None or _to_float(text) is not None:
54
+ return False
55
+ if _to_timestamp(text) is not None:
56
+ return False
57
+ return True
58
+
59
+
60
+ def _float_text(value: float) -> str:
61
+ if value != value:
62
+ return ".nan"
63
+ if value == float("inf"):
64
+ return ".inf"
65
+ if value == float("-inf"):
66
+ return "-.inf"
67
+ return repr(value)
68
+
69
+
70
+ _MAX_DEPTH = 500
71
+
72
+
73
+ def _entries(value, emit, blob, depth, sort_keys):
74
+ """Emits build entries (document order) and blob slices; returns
75
+ True on success, False on unsupported nesting depth. The scalar
76
+ arms are fully inlined — one function call per node was a
77
+ measurable fraction of the walk in CPython."""
78
+ if depth > _MAX_DEPTH:
79
+ return False
80
+
81
+ if value is None:
82
+ emit(F.BUILD_SCALAR, F.STYLE_PLAIN, len(blob), 4)
83
+ blob += b"null"
84
+ elif value is True:
85
+ emit(F.BUILD_SCALAR, F.STYLE_PLAIN, len(blob), 4)
86
+ blob += b"true"
87
+ elif value is False:
88
+ emit(F.BUILD_SCALAR, F.STYLE_PLAIN, len(blob), 5)
89
+ blob += b"false"
90
+ elif type(value) is int:
91
+ buf = str(value).encode()
92
+ emit(F.BUILD_SCALAR, F.STYLE_PLAIN, len(blob), len(buf))
93
+ blob += buf
94
+ elif type(value) is float:
95
+ buf = _float_text(value).encode()
96
+ emit(F.BUILD_SCALAR, F.STYLE_PLAIN, len(blob), len(buf))
97
+ blob += buf
98
+ elif type(value) is str:
99
+ buf = value.encode("utf-8")
100
+ emit(F.BUILD_SCALAR,
101
+ F.STYLE_PLAIN if _plain_ok(value) else F.STYLE_DOUBLE_QUOTED,
102
+ len(blob), len(buf))
103
+ blob += buf
104
+ elif isinstance(value, _dt.datetime):
105
+ buf = value.isoformat(sep=" ").encode()
106
+ emit(F.BUILD_SCALAR, F.STYLE_PLAIN, len(blob), len(buf))
107
+ blob += buf
108
+ elif isinstance(value, _dt.date):
109
+ buf = value.isoformat().encode()
110
+ emit(F.BUILD_SCALAR, F.STYLE_PLAIN, len(blob), len(buf))
111
+ blob += buf
112
+ elif isinstance(value, dict):
113
+ emit(F.BUILD_MAP, 0, 0, 0)
114
+ items = list(value.items())
115
+ if sort_keys:
116
+ try:
117
+ items.sort(key=lambda kv: (str(type(kv[0])), str(kv[0])))
118
+ except TypeError:
119
+ pass
120
+ for k, v in items:
121
+ # the host's plain-safety decides keys (a '<<' or
122
+ # resolvable-text key must not re-shape or merge on
123
+ # reparse — the C table cannot know the reading schema)
124
+ if not _entries(k, emit, blob, depth + 1, sort_keys):
125
+ return False
126
+ if not _entries(v, emit, blob, depth + 1, sort_keys):
127
+ return False
128
+ emit(F.BUILD_END, 0, 0, 0)
129
+ elif isinstance(value, (list, tuple)):
130
+ emit(F.BUILD_SEQ, 0, 0, 0)
131
+ for item in value:
132
+ if not _entries(item, emit, blob, depth + 1, sort_keys):
133
+ return False
134
+ emit(F.BUILD_END, 0, 0, 0)
135
+ elif isinstance(value, (set, frozenset)):
136
+ emit(F.BUILD_SEQ, 0, 0, 0)
137
+ for item in sorted(value, key=repr):
138
+ if not _entries(item, emit, blob, depth + 1, sort_keys):
139
+ return False
140
+ emit(F.BUILD_END, 0, 0, 0)
141
+ else:
142
+ raise TypeError(f"cannot dump {type(value).__name__!r} safely")
143
+ return True
144
+
145
+
146
+ def dump(value, *, sort_keys: bool = True) -> str:
147
+ """Serialize one Python value as a single YAML document.
148
+
149
+ PyYAML safe_dump defaults: block style, keys sorted, unicode
150
+ allowed. Raises TypeError for types with no safe form.
151
+ """
152
+ parts = []
153
+ pack = F.BUILD_ENTRY.pack
154
+ blob = bytearray()
155
+ count = 0
156
+
157
+ def emit(op, style, off, ln):
158
+ nonlocal count
159
+ parts.append(pack(op, style, 0, off, ln))
160
+ count += 1
161
+
162
+ if not _entries(value, emit, blob, 0, sort_keys):
163
+ raise F.YeptrisError("dump: object nests too deeply")
164
+ doc = F._lib.yeptris_document_new()
165
+ if not doc:
166
+ raise F.YeptrisError("document allocation failed")
167
+ try:
168
+ rc = F._lib.yeptris_document_build(
169
+ doc, b"".join(parts), count, bytes(blob), len(blob)
170
+ )
171
+ if rc != F.OK:
172
+ raise F.YeptrisError(f"document_build failed: {rc}")
173
+ length = F._sz(0)
174
+ out = F._lib.yeptris_serialize(doc, ctypes.byref(length))
175
+ if not out:
176
+ raise F.YeptrisError("serialize failed")
177
+ return F.read_owned(out, length.value).decode("utf-8")
178
+ finally:
179
+ F._lib.yeptris_document_free(doc)
@@ -0,0 +1,225 @@
1
+ """ctypes bindings to libyeptris — one signature, declared once.
2
+
3
+ No C extension, no compile step: the shared library is dlopen'd the
4
+ way the FFI gem does it for yeptris-ruby. Search order:
5
+ 1. $YEPTRIS_LIB_PATH
6
+ 2. a vendored yeptris/_platform/<tag>/libyeptris.* next to the package
7
+ 3. a sibling C checkout's build directory (development)
8
+
9
+ The event record layout (36 bytes) is ABI-pinned in the C header and
10
+ mirrored here; `_RECORD` unpacks one record in a single call.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ctypes
16
+ import os
17
+ import struct
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ # YeptrisStatus (error.h)
22
+ OK = 0
23
+ ERROR_PARSE = 1
24
+ ERROR_MEMORY = 2
25
+ ERROR_DEPTH = 3
26
+ ERROR_ENCODING = 4
27
+ ERROR_IO = 5
28
+ ERROR_ARG = 6
29
+ ERROR_UNSUPPORTED = 7
30
+ ERROR_INTERNAL = 8
31
+
32
+ # YeptrisSchema (resolve.h)
33
+ SCHEMA_12_CORE = 0
34
+ SCHEMA_11_COMPAT = 1
35
+
36
+ # YeptrisEventType (events.h)
37
+ STREAM_START = 1
38
+ STREAM_END = 2
39
+ DOCUMENT_START = 3
40
+ DOCUMENT_END = 4
41
+ SEQUENCE_START = 5
42
+ SEQUENCE_END = 6
43
+ MAPPING_START = 7
44
+ MAPPING_END = 8
45
+ SCALAR = 9
46
+ ALIAS = 10
47
+
48
+ # YeptrisTagId (resolve.h)
49
+ TAG_STR = 0
50
+ TAG_INT = 1
51
+ TAG_FLOAT = 2
52
+ TAG_BOOL = 3
53
+ TAG_NULL = 4
54
+ TAG_TIMESTAMP = 5
55
+ TAG_SEQ = 6
56
+ TAG_MAP = 7
57
+ TAG_BINARY = 8
58
+ TAG_MERGE = 9
59
+ TAG_VALUE = 10
60
+
61
+ # YeptrisScalarStyle (dom.h)
62
+ STYLE_PLAIN = 1
63
+ STYLE_SINGLE_QUOTED = 2
64
+ STYLE_DOUBLE_QUOTED = 3
65
+ STYLE_LITERAL = 4
66
+ STYLE_FOLDED = 5
67
+
68
+ # YeptrisEventRecord: type, style, flags, tag_id (uint8 x4) then
69
+ # line, col, value_off, value_len, anchor_off, anchor_len, tag_off,
70
+ # tag_len (uint32 x8). sizeof == 36, ABI-pinned.
71
+ _RECORD = struct.Struct("<4B8I")
72
+ RECORD_SIZE = _RECORD.size
73
+ assert RECORD_SIZE == 36
74
+
75
+ # Flag bits (events.h)
76
+ EF_FLOW = 1 << 0
77
+ EF_EXPLICIT = 1 << 1
78
+ EF_IMPLICIT = 1 << 2
79
+
80
+
81
+ class YeptrisError(Exception):
82
+ """Base: something went wrong inside or around the library."""
83
+
84
+
85
+ class ParseError(YeptrisError):
86
+ """Malformed YAML. Carries the 1-based line and column."""
87
+
88
+ def __init__(self, message: str, line: int = 0, column: int = 0):
89
+ super().__init__(f"{message} at line {line}, column {column}"
90
+ if line else message)
91
+ self.line = line
92
+ self.column = column
93
+
94
+
95
+ def _candidate_paths():
96
+ env = os.environ.get("YEPTRIS_LIB_PATH")
97
+ if env:
98
+ yield Path(env)
99
+ here = Path(__file__).resolve().parent
100
+ for p in sorted(here.glob("_platform/*/libyeptris.*")):
101
+ yield p
102
+ names = ["libyeptris.dylib", "libyeptris.so"]
103
+ for build in ("build", "build-validate"):
104
+ for name in names:
105
+ yield here.parent.parent / "yeptris" / build / "src" / name
106
+
107
+
108
+ def _load_lib() -> ctypes.CDLL:
109
+ tried = []
110
+ for path in _candidate_paths():
111
+ try:
112
+ if path.exists():
113
+ return ctypes.CDLL(str(path))
114
+ tried.append(str(path))
115
+ except OSError:
116
+ tried.append(str(path))
117
+ raise YeptrisError(
118
+ "could not load the libyeptris library. Set YEPTRIS_LIB_PATH to a "
119
+ "libyeptris.{so,dylib,dll}, install a platform wheel, or build the "
120
+ "sibling C checkout. Tried: " + ", ".join(tried)
121
+ )
122
+
123
+
124
+ _lib = _load_lib()
125
+
126
+ _u8p = ctypes.POINTER(ctypes.c_char)
127
+ _p = ctypes.c_void_p
128
+ _sz = ctypes.c_size_t
129
+
130
+ _lib.yeptris_recorder_new_ex.argtypes = [ctypes.c_int]
131
+ _lib.yeptris_recorder_new_ex.restype = _p
132
+ _lib.yeptris_recorder_feed.argtypes = [_p, ctypes.c_char_p, _sz, ctypes.c_int]
133
+ _lib.yeptris_recorder_feed.restype = ctypes.c_int
134
+ _lib.yeptris_recorder_records.argtypes = [_p, ctypes.POINTER(_sz)]
135
+ _lib.yeptris_recorder_records.restype = _u8p
136
+ _lib.yeptris_recorder_arena.argtypes = [_p, ctypes.POINTER(_sz)]
137
+ _lib.yeptris_recorder_arena.restype = ctypes.c_char_p
138
+ _lib.yeptris_recorder_free.argtypes = [_p]
139
+
140
+ _lib.yeptris_last_error.argtypes = [ctypes.POINTER(ctypes.c_uint32),
141
+ ctypes.POINTER(ctypes.c_uint32)]
142
+ _lib.yeptris_last_error.restype = ctypes.c_char_p
143
+
144
+ _lib.yeptris_document_new.argtypes = []
145
+ _lib.yeptris_document_new.restype = _p
146
+ _lib.yeptris_document_free.argtypes = [_p]
147
+ _lib.yeptris_document_set_root.argtypes = [_p, _p]
148
+ _lib.yeptris_document_set_root.restype = ctypes.c_int
149
+ _lib.yeptris_node_new_scalar.argtypes = [_p, ctypes.c_char_p, _sz, ctypes.c_int]
150
+ _lib.yeptris_node_new_scalar.restype = _p
151
+ _lib.yeptris_node_new_sequence.argtypes = [_p]
152
+ _lib.yeptris_node_new_sequence.restype = _p
153
+ _lib.yeptris_node_new_mapping.argtypes = [_p]
154
+ _lib.yeptris_node_new_mapping.restype = _p
155
+ _lib.yeptris_node_seq_add.argtypes = [_p, _p]
156
+ _lib.yeptris_node_seq_add.restype = ctypes.c_int
157
+ _lib.yeptris_node_map_add.argtypes = [_p, ctypes.c_char_p, _sz, _p]
158
+ _lib.yeptris_node_map_add.restype = ctypes.c_int
159
+ _lib.yeptris_node_map_add_node.argtypes = [_p, _p, _p]
160
+ _lib.yeptris_node_map_add_node.restype = ctypes.c_int
161
+ _lib.yeptris_document_build.argtypes = [_p, ctypes.c_void_p, _sz,
162
+ ctypes.c_char_p, _sz]
163
+ _lib.yeptris_document_build.restype = ctypes.c_int
164
+
165
+ # YeptrisBuildEntry: op, style, reserved, off, len — 12 bytes, pinned
166
+ BUILD_ENTRY = struct.Struct("<BBHII")
167
+ BUILD_SCALAR, BUILD_SEQ, BUILD_MAP, BUILD_END = 1, 2, 3, 4
168
+
169
+ # restype c_void_p: the malloc'd buffer's pointer must stay a pointer
170
+ # so it can be freed (c_char_p would auto-convert and leak it)
171
+ _lib.yeptris_serialize.argtypes = [_p, ctypes.POINTER(_sz)]
172
+ _lib.yeptris_serialize.restype = ctypes.c_void_p
173
+
174
+ # serialize() returns a malloc'd buffer (caller frees, emit.h) — the
175
+ # library allocates with the system allocator, so libc free is exact
176
+ libc_free = ctypes.CDLL(None).free
177
+ libc_free.argtypes = [ctypes.c_void_p]
178
+
179
+
180
+ def last_error():
181
+ line = ctypes.c_uint32(0)
182
+ col = ctypes.c_uint32(0)
183
+ msg = _lib.yeptris_last_error(ctypes.byref(line), ctypes.byref(col))
184
+ return (msg.decode("utf-8", "replace") if msg else "parse error",
185
+ line.value, col.value)
186
+
187
+
188
+ def free_buffer(buf) -> None:
189
+ if buf:
190
+ libc_free(ctypes.cast(buf, ctypes.c_void_p))
191
+
192
+
193
+ def read_owned(ptr, length) -> bytes:
194
+ """The serialize* contract: caller copies then frees."""
195
+ if not ptr:
196
+ return b""
197
+ try:
198
+ return ctypes.string_at(ptr, length)
199
+ finally:
200
+ free_buffer(ptr)
201
+
202
+
203
+ def drain(yaml: bytes, schema: int):
204
+ """One parse + one bulk read: the flat record array and the arena.
205
+
206
+ Returns (records_bytes, arena_bytes) — the FFI tax is O(1) per
207
+ parse, never per event (the same seam yeptris-ruby rides).
208
+ """
209
+ rec = _lib.yeptris_recorder_new_ex(schema)
210
+ if not rec:
211
+ raise YeptrisError("recorder allocation failed")
212
+ try:
213
+ st = _lib.yeptris_recorder_feed(rec, yaml, len(yaml), 1)
214
+ if st != OK:
215
+ msg, line, col = last_error()
216
+ raise ParseError(msg, line, col)
217
+ n = ctypes.c_size_t(0)
218
+ raw = _lib.yeptris_recorder_records(rec, ctypes.byref(n))
219
+ records = ctypes.string_at(raw, n.value * RECORD_SIZE) if n.value else b""
220
+ alen = ctypes.c_size_t(0)
221
+ arena_p = _lib.yeptris_recorder_arena(rec, ctypes.byref(alen))
222
+ arena = arena_p[:alen.value] if alen.value else b""
223
+ return records, arena
224
+ finally:
225
+ _lib.yeptris_recorder_free(rec)
@@ -0,0 +1,309 @@
1
+ """Bulk-drain loader: events to Python values.
2
+
3
+ One drain (records + arena read once), then a pure-Python walk over
4
+ the unpacked record array — no per-event FFI calls. Typing comes
5
+ from the C resolver's tag_id (the typing SSOT); the conversion
6
+ functions below mirror PyYAML's SafeLoader constructors exactly
7
+ (float sexagesimal folds reversed digits; timestamps carry their
8
+ offset as tzinfo; merge keys are resolver-driven, so a quoted '<<'
9
+ is a literal key).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime as _dt
15
+ import re
16
+
17
+ from . import _ffi as F
18
+
19
+ _RECORD = F._RECORD
20
+
21
+ _BOOL_TRUE = {"yes", "true", "on"}
22
+ _BOOL_FALSE = {"no", "false", "off"}
23
+
24
+ _INT_DEC = re.compile(r"^[-+]?(0|[1-9][0-9_]*)$")
25
+ _INT_HEX = re.compile(r"^[-+]?0x[0-9a-fA-F_]+$")
26
+ _INT_OCT = re.compile(r"^[-+]?0[0-7_]+$")
27
+ _INT_BIN = re.compile(r"^[-+]?0b[01_]+$")
28
+ _INT_SEXAGESIMAL = re.compile(r"^[-+]?[1-9][0-9_]*(:[0-5]?[0-9])+$")
29
+
30
+ # PyYAML's float resolver: the dot is required and the exponent
31
+ # carries a mandatory sign — "1e3" is a STRING
32
+ _FLOAT = re.compile(
33
+ r"^[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?$"
34
+ r"|^\.[0-9_]+(?:[eE][-+][0-9]+)?$"
35
+ r"|^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*$"
36
+ )
37
+ _FLOAT_INF = re.compile(r"^[-+]?\.(?:inf|Inf|INF)$")
38
+ _FLOAT_NAN = re.compile(r"^\.(?:nan|NaN|NAN)$")
39
+
40
+ # PyYAML's timestamp regexp (SafeConstructor.timestamp_regexp)
41
+ _TIMESTAMP = re.compile(
42
+ r"""^(?P<year>[0-9][0-9][0-9][0-9])
43
+ -(?P<month>[0-9][0-9]?)
44
+ -(?P<day>[0-9][0-9]?)
45
+ (?:(?:[Tt]|[ \t]+)
46
+ (?P<hour>[0-9][0-9]?)
47
+ :(?P<minute>[0-9][0-9])
48
+ :(?P<second>[0-9][0-9])
49
+ (?:\.(?P<fraction>[0-9]*))?
50
+ (?:[ \t]*(?P<tz>Z|(?P<tz_sign>[-+])(?P<tz_hour>[0-9][0-9]?)
51
+ (?::(?P<tz_minute>[0-9][0-9]))?))?)?$""",
52
+ re.VERBOSE,
53
+ )
54
+
55
+
56
+ def _to_int(text: str):
57
+ if _INT_DEC.match(text):
58
+ return int(text.replace("_", ""), 10)
59
+ if _INT_HEX.match(text):
60
+ return int(text.replace("_", ""), 16)
61
+ if _INT_OCT.match(text):
62
+ return int(text.replace("_", ""), 8)
63
+ if _INT_BIN.match(text):
64
+ return int(text.replace("_", ""), 2)
65
+ if _INT_SEXAGESIMAL.match(text):
66
+ sign = -1 if text[0] == "-" else 1
67
+ value = 0
68
+ for part in text.lstrip("+-").split(":"):
69
+ value = value * 60 + int(part.replace("_", ""))
70
+ return sign * value
71
+ return None
72
+
73
+
74
+ def _to_float(text: str):
75
+ """PyYAML's construct_yaml_float, faithfully."""
76
+ t = text.replace("_", "").lower()
77
+ sign = -1 if t[0] == "-" else 1
78
+ if t[0] in "+-":
79
+ t = t[1:]
80
+ if t == ".inf":
81
+ return sign * float("inf")
82
+ if t == ".nan":
83
+ return float("nan")
84
+ if ":" in t:
85
+ # reversed digits, base 1, *= 60 — the fraction part rides
86
+ # its segment as a float
87
+ value = 0.0
88
+ base = 1.0
89
+ for part in reversed(t.split(":")):
90
+ value += float(part) * base
91
+ base *= 60
92
+ return sign * value
93
+ if _FLOAT.match(text):
94
+ return sign * float(t)
95
+ return None
96
+
97
+
98
+ def _to_timestamp(text: str):
99
+ m = _TIMESTAMP.match(text)
100
+ if m is None:
101
+ return None
102
+ g = m.groupdict()
103
+ year, month, day = int(g["year"]), int(g["month"]), int(g["day"])
104
+ if not g["hour"]:
105
+ return _dt.date(year, month, day)
106
+ fraction = g["fraction"] or ""
107
+ micro = int(fraction[:6].ljust(6, "0")) if fraction else 0
108
+ tzinfo = None
109
+ if g["tz_sign"]:
110
+ delta = _dt.timedelta(hours=int(g["tz_hour"]),
111
+ minutes=int(g["tz_minute"] or 0))
112
+ if g["tz_sign"] == "-":
113
+ delta = -delta
114
+ tzinfo = _dt.timezone(delta)
115
+ elif g["tz"] == "Z":
116
+ tzinfo = _dt.timezone.utc
117
+ return _dt.datetime(year, month, day, int(g["hour"]), int(g["minute"]),
118
+ int(g["second"]), micro, tzinfo=tzinfo)
119
+
120
+
121
+ def _value(value: bytes, tag_id: int, flags: int):
122
+ """A scalar's Python value: byte-level fast paths for the shapes
123
+ that dominate real documents (int()/float() accept bytes), the
124
+ full PyYAML-conversion layer for everything else."""
125
+ if tag_id == F.TAG_STR:
126
+ text = value.decode("utf-8")
127
+ if flags & F.EF_IMPLICIT:
128
+ # the C resolver tags only FULL timestamps (Psych's
129
+ # grammar); PyYAML also accepts date-only forms — a
130
+ # plain scalar shaped like a date becomes one here. The
131
+ # dumper quotes strings that would re-shape.
132
+ ts = _to_timestamp(text)
133
+ if ts is not None:
134
+ return ts
135
+ return text
136
+ if tag_id == F.TAG_INT:
137
+ # digits-only (with optional sign, no leading zero — PyYAML
138
+ # reads those as octal) is the overwhelming shape
139
+ body = value[1:] if value[:1] in (b"-", b"+") else value
140
+ if body.isdigit() and (body[:1] != b"0" or len(body) == 1):
141
+ return int(value)
142
+ text = value.decode("utf-8")
143
+ v = _to_int(text)
144
+ return text if v is None else v
145
+ if tag_id == F.TAG_NULL:
146
+ return None
147
+ if tag_id == F.TAG_FLOAT:
148
+ # the C tag follows the 1.1 grammar; PyYAML additionally
149
+ # requires the dot — with a dot and no sexagesimal ':' the
150
+ # C float() is exact
151
+ if b":" not in value and b"." in value:
152
+ try:
153
+ return float(value)
154
+ except ValueError:
155
+ pass
156
+ text = value.decode("utf-8")
157
+ v = _to_float(text)
158
+ return text if v is None else v
159
+ text = value.decode("utf-8")
160
+ if tag_id == F.TAG_BOOL:
161
+ # Psych resolves single-char y/n as bool; PyYAML does not —
162
+ # this binding follows PyYAML (the override yeptris-ruby
163
+ # documents for Psych parity)
164
+ if len(text) == 1:
165
+ return text
166
+ lowered = text.lower()
167
+ if lowered in _BOOL_TRUE:
168
+ return True
169
+ if lowered in _BOOL_FALSE:
170
+ return False
171
+ return text
172
+ if tag_id == F.TAG_TIMESTAMP:
173
+ v = _to_timestamp(text)
174
+ return text if v is None else v
175
+ return text
176
+
177
+
178
+ def _merge(target: dict, source) -> None:
179
+ """'<<' merge: existing keys win; sequences merge in order."""
180
+ if isinstance(source, dict):
181
+ for k, v in source.items():
182
+ target.setdefault(k, v)
183
+ elif isinstance(source, list):
184
+ for item in source:
185
+ if isinstance(item, dict):
186
+ for k, v in item.items():
187
+ target.setdefault(k, v)
188
+
189
+
190
+ def load_all(yaml, schema: int = F.SCHEMA_11_COMPAT):
191
+ """Every document in the stream, in order."""
192
+ records, arena = F.drain(_as_bytes(yaml), schema)
193
+
194
+ docs: list = []
195
+ stack: list = []
196
+ pending_key: list = [None] # key awaiting its value, per open map
197
+ pending_tag: list = [None] # the key's tag_id (merge is tag-driven)
198
+ anchors: dict = {}
199
+ merge_targets: dict = {} # id(fresh container) -> dict to merge into
200
+
201
+ # hot loop: C-level record iteration with the placement logic
202
+ # inlined (this walk is the bulk of load time; a function call
203
+ # per event is measurable in CPython)
204
+ for rec in _RECORD.iter_unpack(records):
205
+ etype = rec[0]
206
+ tag_id = rec[3]
207
+ if etype == F.SCALAR:
208
+ v_off, v_len = rec[6], rec[7]
209
+ value = arena[v_off:v_off + v_len] if v_len else b""
210
+ v = _value(value, tag_id, rec[2])
211
+ a_len = rec[9]
212
+ if a_len:
213
+ anchors[arena[rec[8]:rec[8] + a_len]] = v
214
+ if stack:
215
+ parent = stack[-1]
216
+ if type(parent) is list:
217
+ parent.append(v)
218
+ else:
219
+ key = pending_key[-1]
220
+ if key is None:
221
+ pending_key[-1] = v
222
+ pending_tag[-1] = tag_id
223
+ else:
224
+ pending_key[-1] = None
225
+ if tag_id == F.TAG_MERGE:
226
+ if type(v) in (dict, list) and not v:
227
+ merge_targets[id(v)] = parent
228
+ else:
229
+ _merge(parent, v)
230
+ else:
231
+ parent[key] = v
232
+ else:
233
+ docs[-1] = v
234
+ elif etype == F.MAPPING_START or etype == F.SEQUENCE_START:
235
+ fresh = {} if etype == F.MAPPING_START else []
236
+ a_len = rec[9]
237
+ if a_len:
238
+ anchors[arena[rec[8]:rec[8] + a_len]] = fresh
239
+ if stack:
240
+ parent = stack[-1]
241
+ if type(parent) is list:
242
+ parent.append(fresh)
243
+ else:
244
+ key = pending_key[-1]
245
+ if key is None:
246
+ pending_key[-1] = fresh
247
+ pending_tag[-1] = F.TAG_STR
248
+ else:
249
+ pending_key[-1] = None
250
+ if key == "<<" and pending_tag[-1] == F.TAG_MERGE and not fresh:
251
+ merge_targets[id(fresh)] = parent
252
+ else:
253
+ parent[key] = fresh
254
+ else:
255
+ docs[-1] = fresh
256
+ stack.append(fresh)
257
+ pending_key.append(None)
258
+ pending_tag.append(None)
259
+ elif etype == F.MAPPING_END or etype == F.SEQUENCE_END:
260
+ closed = stack.pop()
261
+ pending_key.pop()
262
+ pending_tag.pop()
263
+ target = merge_targets.pop(id(closed), None)
264
+ if target is not None:
265
+ _merge(target, closed)
266
+ elif etype == F.DOCUMENT_START:
267
+ docs.append(None)
268
+ elif etype == F.ALIAS:
269
+ # the alias NAME lives in the value field (events.h)
270
+ rec_v_len = rec[7]
271
+ v = anchors.get(arena[rec[6]:rec[6] + rec_v_len] if rec_v_len else b"")
272
+ if stack:
273
+ parent = stack[-1]
274
+ if type(parent) is list:
275
+ parent.append(v)
276
+ else:
277
+ key = pending_key[-1]
278
+ if key is None:
279
+ pending_key[-1] = v
280
+ pending_tag[-1] = F.TAG_STR
281
+ else:
282
+ pending_key[-1] = None
283
+ if key == "<<" and pending_tag[-1] == F.TAG_MERGE:
284
+ _merge(parent, v)
285
+ else:
286
+ parent[key] = v
287
+ else:
288
+ docs[-1] = v
289
+ return docs
290
+
291
+
292
+ def _as_bytes(yaml) -> bytes:
293
+ if isinstance(yaml, (bytes, bytearray)):
294
+ return bytes(yaml)
295
+ if isinstance(yaml, str):
296
+ return yaml.encode("utf-8")
297
+ read = getattr(yaml, "read", None)
298
+ if read is None:
299
+ raise TypeError("expected str, bytes, or a file-like object")
300
+ data = read()
301
+ if isinstance(data, str):
302
+ return data.encode("utf-8")
303
+ return bytes(data)
304
+
305
+
306
+ def load(yaml, schema: int = F.SCHEMA_11_COMPAT):
307
+ """The first document of the stream, or None when empty."""
308
+ docs = load_all(yaml, schema)
309
+ return docs[0] if docs else None
@@ -0,0 +1,36 @@
1
+ """The PyYAML-compatible surface: safe_load / safe_load_all / safe_dump.
2
+
3
+ Semantics target PyYAML's SafeLoader/SafeDumper (YAML 1.1 implicit
4
+ typing, timestamps, merge keys); the differential tests pin every
5
+ divergence explicitly. `import yeptris.yaml as yaml` and existing
6
+ code that calls yaml.safe_load keeps working.
7
+ """
8
+
9
+ from ._dumper import dump as _dump
10
+ from ._loader import load_all as _load_all
11
+
12
+ __all__ = ["safe_load", "safe_load_all", "safe_dump", "load", "dump"]
13
+
14
+
15
+ def safe_load(stream):
16
+ """First document of the stream (None when empty)."""
17
+ docs = _load_all(stream)
18
+ return docs[0] if docs else None
19
+
20
+
21
+ def safe_load_all(stream):
22
+ """Every document in the stream, in order."""
23
+ return _load_all(stream)
24
+
25
+
26
+ def safe_dump(data, stream=None, *, sort_keys=True, **_ignored):
27
+ """Serialize as one YAML document (PyYAML safe_dump defaults)."""
28
+ text = _dump(data, sort_keys=sort_keys)
29
+ if stream is None:
30
+ return text
31
+ stream.write(text)
32
+ return None
33
+
34
+
35
+ load = safe_load
36
+ dump = safe_dump
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: yeptris
3
+ Version: 0.1.0
4
+ Summary: YAML for Python at libleptris speed — an FFI-based (no C extension) binding over libyeptris, PyYAML-compatible
5
+ Author-email: "Ribose Inc." <open.source@ribose.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/leptris/yeptris
8
+ Project-URL: Source, https://github.com/leptris/yeptris-py
9
+ Keywords: yaml,parser,libyaml,pyyaml
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Requires-Dist: pyyaml; extra == "test"
25
+
26
+ # yeptris — YAML for Python at libleptris speed
27
+
28
+ An FFI-based (no C extension) YAML library over
29
+ [libyeptris](https://github.com/leptris/yeptris) — the YAML
30
+ counterpart of libleptris. PyYAML-compatible semantics, one shared
31
+ library, zero compilation at install.
32
+
33
+ ## Install (development)
34
+
35
+ ```sh
36
+ # the sibling C checkout: ~/src/leptris/yeptris
37
+ cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DYEPTRIS_BUILD_SHARED=ON
38
+ cmake --build build
39
+
40
+ cd ~/src/leptris/yeptris-py
41
+ YEPTRIS_LIB_PATH=../yeptris/build/src/libyeptris.dylib python3 -m pytest
42
+ ```
43
+
44
+ Without `YEPTRIS_LIB_PATH` the loader falls back to a vendored
45
+ `yeptris/_platform/<tag>/` copy, then to the sibling checkout's
46
+ build directory. Any `libyeptris.{so,dylib,dll}` path works.
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ import yeptris
52
+ from yeptris import yaml # PyYAML-compatible surface
53
+
54
+ yaml.safe_load("name: yeptris\nrating: 10\n")
55
+ # {'name': 'yeptris', 'rating': 10}
56
+
57
+ yaml.safe_load_all("--- 1\n--- two\n") # [1, 'two']
58
+ yaml.safe_dump({"b": 2, "a": [1, "x"]}) # 'a:\n - 1\n - x\nb: 2\n'
59
+
60
+ yeptris.load("k: v") # the neutral surface
61
+ yeptris.dump({"k": [1, 2]})
62
+ ```
63
+
64
+ Typing follows PyYAML's SafeLoader (YAML 1.1 implicit typing):
65
+ `yes/no/on/off` booleans, `0x`/leading-0/`0b`/sexagesimal integers,
66
+ dot-required floats, timestamps with offsets, merge keys, anchor
67
+ identity. Every deliberate divergence is pinned by a test.
68
+
69
+ ## Design
70
+
71
+ One parse, one bulk drain: the record array and string arena are
72
+ read in two FFI calls, then a pure-Python walk over the unpacked
73
+ records — the FFI tax is O(1) per document, never per event (the
74
+ same seam yeptris-ruby rides). The 36-byte record layout is
75
+ ABI-pinned in the C header and mirrored in `_ffi.py`.
76
+
77
+ ## Performance
78
+
79
+ `python3 bench.py` — same-process comparison against PyYAML (pure)
80
+ and CSafeLoader/CDumper (libyaml C extensions):
81
+
82
+ - **load**: 28-39x PyYAML pure, 3.8-6.1x CSafeLoader
83
+ - **dump**: ~1.7x PyYAML pure, ~2x behind CDumper (which walks the
84
+ tree entirely in C — a no-C-extension design's ceiling is the
85
+ Python walk itself; the tree raises through ONE
86
+ `yeptris_document_build` call)
87
+
88
+ Both directions are O(chunks) in FFI calls: loads drain records in
89
+ two calls, dumps build through one flat entry array.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_dump.py
4
+ tests/test_load.py
5
+ yeptris/__init__.py
6
+ yeptris/_dumper.py
7
+ yeptris/_ffi.py
8
+ yeptris/_loader.py
9
+ yeptris/yaml.py
10
+ yeptris.egg-info/PKG-INFO
11
+ yeptris.egg-info/SOURCES.txt
12
+ yeptris.egg-info/dependency_links.txt
13
+ yeptris.egg-info/requires.txt
14
+ yeptris.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+
2
+ [test]
3
+ pytest
4
+ pyyaml
@@ -0,0 +1 @@
1
+ yeptris