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 +89 -0
- yeptris-0.1.0/README.md +64 -0
- yeptris-0.1.0/pyproject.toml +38 -0
- yeptris-0.1.0/setup.cfg +4 -0
- yeptris-0.1.0/tests/test_dump.py +107 -0
- yeptris-0.1.0/tests/test_load.py +154 -0
- yeptris-0.1.0/yeptris/__init__.py +17 -0
- yeptris-0.1.0/yeptris/_dumper.py +179 -0
- yeptris-0.1.0/yeptris/_ffi.py +225 -0
- yeptris-0.1.0/yeptris/_loader.py +309 -0
- yeptris-0.1.0/yeptris/yaml.py +36 -0
- yeptris-0.1.0/yeptris.egg-info/PKG-INFO +89 -0
- yeptris-0.1.0/yeptris.egg-info/SOURCES.txt +14 -0
- yeptris-0.1.0/yeptris.egg-info/dependency_links.txt +1 -0
- yeptris-0.1.0/yeptris.egg-info/requires.txt +4 -0
- yeptris-0.1.0/yeptris.egg-info/top_level.txt +1 -0
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.
|
yeptris-0.1.0/README.md
ADDED
|
@@ -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"]
|
yeptris-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yeptris
|