tmsgpack 0.2.22__tar.gz → 0.3.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.
- {tmsgpack-0.2.22 → tmsgpack-0.3.0}/MANIFEST.in +2 -2
- tmsgpack-0.3.0/PKG-INFO +11 -0
- tmsgpack-0.3.0/build_pyx.py +67 -0
- tmsgpack-0.3.0/pyproject.toml +37 -0
- tmsgpack-0.3.0/setup.cfg +4 -0
- {tmsgpack-0.2.22 → tmsgpack-0.3.0}/setup.py +6 -3
- tmsgpack-0.3.0/tests/test_build_pyx.py +65 -0
- tmsgpack-0.3.0/tests/test_codec.py +197 -0
- tmsgpack-0.3.0/tests/test_round_trip.py +190 -0
- tmsgpack-0.3.0/tmsgpack/__init__.py +0 -0
- {tmsgpack-0.2.22 → tmsgpack-0.3.0}/tmsgpack/api.py +15 -6
- tmsgpack-0.3.0/tmsgpack/codec.py +124 -0
- tmsgpack-0.3.0/tmsgpack/core.c +33862 -0
- tmsgpack-0.2.22/tmsgpack/cython.pyx → tmsgpack-0.3.0/tmsgpack/core.pyx +2 -2
- tmsgpack-0.3.0/tmsgpack/src-parts/01-imports +10 -0
- tmsgpack-0.3.0/tmsgpack/src-parts/02-constants +53 -0
- tmsgpack-0.3.0/tmsgpack/src-parts/03-encode-fn +153 -0
- tmsgpack-0.3.0/tmsgpack/src-parts/04-decode-fn +140 -0
- tmsgpack-0.3.0/tmsgpack/src-parts/05-encode-buffer +66 -0
- tmsgpack-0.3.0/tmsgpack/src-parts/06-decode-buffer +72 -0
- tmsgpack-0.3.0/tmsgpack/src-parts/07-encode-buffer-slow +33 -0
- tmsgpack-0.3.0/tmsgpack/src-parts/08-decode-buffer-slow +37 -0
- tmsgpack-0.3.0/tmsgpack/src-parts/09-exceptions +1 -0
- tmsgpack-0.3.0/tmsgpack.egg-info/PKG-INFO +11 -0
- tmsgpack-0.3.0/tmsgpack.egg-info/SOURCES.txt +27 -0
- tmsgpack-0.3.0/tmsgpack.egg-info/requires.txt +2 -0
- tmsgpack-0.2.22/Automatic-Version +0 -1
- tmsgpack-0.2.22/COPYING +0 -2
- tmsgpack-0.2.22/LICENSE +0 -5
- tmsgpack-0.2.22/PKG-INFO +0 -29
- tmsgpack-0.2.22/pyproject.toml +0 -7
- tmsgpack-0.2.22/setup.cfg +0 -30
- tmsgpack-0.2.22/tmsgpack/__init__.py +0 -14
- tmsgpack-0.2.22/tmsgpack/cython.c +0 -34863
- tmsgpack-0.2.22/tmsgpack.egg-info/PKG-INFO +0 -29
- tmsgpack-0.2.22/tmsgpack.egg-info/SOURCES.txt +0 -16
- {tmsgpack-0.2.22 → tmsgpack-0.3.0}/README.md +0 -0
- {tmsgpack-0.2.22 → tmsgpack-0.3.0}/tmsgpack.egg-info/dependency_links.txt +0 -0
- {tmsgpack-0.2.22 → tmsgpack-0.3.0}/tmsgpack.egg-info/top_level.txt +0 -0
tmsgpack-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tmsgpack
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Typed MessagePack serializer (inspired by but different from msgpack)
|
|
5
|
+
Author-email: Yaakov Belch <yaakov.belch@gmail.com>
|
|
6
|
+
License: ISC
|
|
7
|
+
Project-URL: Source, https://github.com/Yaakov-Belch/semifun
|
|
8
|
+
Project-URL: Tracker, https://github.com/Yaakov-Belch/semifun/issues
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Requires-Dist: xxhash
|
|
11
|
+
Requires-Dist: semifun>=0.1.0
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Generate `tmsgpack/core.pyx` by concatenating `tmsgpack/src-parts/*`.
|
|
2
|
+
|
|
3
|
+
The Cython source is assembled from numbered fragments so that each part can
|
|
4
|
+
be edited on its own. `core.pyx` is the generated result — never edit it.
|
|
5
|
+
|
|
6
|
+
`setup.py` calls `write_pyx()` before cythonizing, so a build always compiles
|
|
7
|
+
the current fragments. The generated file is committed as well, because
|
|
8
|
+
`MANIFEST.in` ships `*.pyx` but not `src-parts/`: an sdist therefore contains
|
|
9
|
+
the assembled source and can be built without regenerating it.
|
|
10
|
+
|
|
11
|
+
`tests/test_build_pyx.py` asserts that the committed file matches what
|
|
12
|
+
`render_pyx()` produces, so the two cannot drift.
|
|
13
|
+
|
|
14
|
+
Run directly to regenerate by hand:
|
|
15
|
+
|
|
16
|
+
uv run python build_pyx.py
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
PACKAGE_ROOT = Path(__file__).parent
|
|
22
|
+
REPO_ROOT = PACKAGE_ROOT.parent
|
|
23
|
+
SRC_PARTS = PACKAGE_ROOT / 'tmsgpack' / 'src-parts'
|
|
24
|
+
PYX_PATH = PACKAGE_ROOT / 'tmsgpack' / 'core.pyx'
|
|
25
|
+
VERSION_FILE = REPO_ROOT / 'VERSION'
|
|
26
|
+
|
|
27
|
+
HEADER = """\
|
|
28
|
+
# THIS FILE IS AUTOMATICALLY CREATED BY build_pyx.py
|
|
29
|
+
# DON'T EDIT THIS FILE. EDIT THE SOURCES, INSTEAD: tmsgpack/src-parts/*
|
|
30
|
+
|
|
31
|
+
__version__ = "{version}"
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_version() -> str:
|
|
37
|
+
"""The single source of truth for the version: repo-root VERSION file."""
|
|
38
|
+
return VERSION_FILE.read_text().strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_pyx() -> str:
|
|
42
|
+
"""Return the full text of `core.pyx` without writing anything."""
|
|
43
|
+
parts = sorted(SRC_PARTS.iterdir())
|
|
44
|
+
if not parts:
|
|
45
|
+
raise FileNotFoundError(f'No source fragments in {SRC_PARTS}')
|
|
46
|
+
return HEADER.format(version=read_version()) + ''.join(
|
|
47
|
+
p.read_text() for p in parts
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_pyx(path: Path) -> bool:
|
|
52
|
+
"""Write the rendered source to `path` if it differs. True when rewritten.
|
|
53
|
+
|
|
54
|
+
Writing only on change keeps the file's mtime stable, so an unchanged
|
|
55
|
+
build does not make Cython recompile. The destination is an argument so
|
|
56
|
+
that tests can exercise this without touching the source tree.
|
|
57
|
+
"""
|
|
58
|
+
new = render_pyx()
|
|
59
|
+
if path.exists() and path.read_text() == new:
|
|
60
|
+
return False
|
|
61
|
+
path.write_text(new)
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == '__main__':
|
|
66
|
+
changed = write_pyx(PYX_PATH)
|
|
67
|
+
print(f'{PYX_PATH}: {"regenerated" if changed else "already current"}')
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tmsgpack"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Typed MessagePack serializer (inspired by but different from msgpack)"
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
|
+
license = {text = "ISC"}
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Yaakov Belch", email = "yaakov.belch@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"xxhash",
|
|
12
|
+
"semifun>=0.1.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
semifun = { workspace = true }
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Source = "https://github.com/Yaakov-Belch/semifun"
|
|
20
|
+
Tracker = "https://github.com/Yaakov-Belch/semifun/issues"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8.0.0",
|
|
25
|
+
"pytest-asyncio>=0.24.0",
|
|
26
|
+
"pytest-timeout>=2.3.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
addopts = "--import-mode=importlib"
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
timeout = 60
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools>=75.0", "Cython>=3.0,<4"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|
tmsgpack-0.3.0/setup.cfg
ADDED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
from setuptools import setup, find_packages
|
|
2
2
|
from Cython.Build import cythonize
|
|
3
3
|
import glob
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
sys.path.insert(0, os.getcwd())
|
|
8
|
+
from build_pyx import PYX_PATH, write_pyx
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
write_pyx(PYX_PATH)
|
|
9
11
|
|
|
12
|
+
pyx_files = glob.glob("tmsgpack/*.pyx")
|
|
10
13
|
|
|
11
14
|
setup(
|
|
12
15
|
name="tmsgpack",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The generated Cython source must match its fragments and the declared version."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
PACKAGE_ROOT = Path(__file__).parent.parent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_build_pyx():
|
|
12
|
+
"""Import `build_pyx.py`, which sits beside setup.py rather than in the package."""
|
|
13
|
+
spec = importlib.util.spec_from_file_location(
|
|
14
|
+
'build_pyx', PACKAGE_ROOT / 'build_pyx.py',
|
|
15
|
+
)
|
|
16
|
+
module = importlib.util.module_from_spec(spec)
|
|
17
|
+
spec.loader.exec_module(module)
|
|
18
|
+
return module
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_committed_pyx_matches_its_fragments():
|
|
22
|
+
"""`core.pyx` is generated; a stale committed copy is a silent trap.
|
|
23
|
+
|
|
24
|
+
An sdist ships the assembled `.pyx` (MANIFEST.in), so a copy that no
|
|
25
|
+
longer matches `src-parts/` would build something nobody wrote.
|
|
26
|
+
"""
|
|
27
|
+
build_pyx = _load_build_pyx()
|
|
28
|
+
assert build_pyx.PYX_PATH.read_text() == build_pyx.render_pyx(), (
|
|
29
|
+
'tmsgpack/core.pyx is out of date with tmsgpack/src-parts/*. '
|
|
30
|
+
'Regenerate it: uv run python build_pyx.py'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_generated_version_matches_the_declared_version():
|
|
35
|
+
"""The version compiled into the extension comes from [project] version."""
|
|
36
|
+
from tmsgpack.core import __version__
|
|
37
|
+
|
|
38
|
+
build_pyx = _load_build_pyx()
|
|
39
|
+
assert __version__ == build_pyx.read_version()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_render_is_deterministic_and_ordered():
|
|
43
|
+
"""Fragments concatenate in numeric-name order, so the output is stable."""
|
|
44
|
+
build_pyx = _load_build_pyx()
|
|
45
|
+
assert build_pyx.render_pyx() == build_pyx.render_pyx()
|
|
46
|
+
|
|
47
|
+
names = [p.name for p in sorted(build_pyx.SRC_PARTS.iterdir())]
|
|
48
|
+
assert names == sorted(names)
|
|
49
|
+
assert names[0].startswith('01-')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_write_pyx_only_writes_when_the_content_differs(tmp_path):
|
|
53
|
+
"""Rewriting an unchanged file would churn its mtime and force a rebuild.
|
|
54
|
+
|
|
55
|
+
Writes to a temporary path: a test must not modify the source tree.
|
|
56
|
+
"""
|
|
57
|
+
build_pyx = _load_build_pyx()
|
|
58
|
+
target = tmp_path / 'core.pyx'
|
|
59
|
+
|
|
60
|
+
assert build_pyx.write_pyx(target) is True # created
|
|
61
|
+
assert build_pyx.write_pyx(target) is False # unchanged, left alone
|
|
62
|
+
|
|
63
|
+
target.write_text('stale')
|
|
64
|
+
assert build_pyx.write_pyx(target) is True # differs, rewritten
|
|
65
|
+
assert target.read_text() == build_pyx.render_pyx()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Integration tests for TmsgpackCodec with DI and the feature registry."""
|
|
2
|
+
|
|
3
|
+
import textwrap
|
|
4
|
+
import pytest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
pytest.importorskip("semifun.di")
|
|
8
|
+
pytest.importorskip("semifun.plugins")
|
|
9
|
+
|
|
10
|
+
from tmsgpack.codec import NoDependencyInjector, TmsgpackCodec
|
|
11
|
+
from semifun.di.injector import DependencyInjector
|
|
12
|
+
from semifun.plugins.testing import create_registry_from_paths
|
|
13
|
+
from semifun.plugins.registry import (
|
|
14
|
+
_feature_map_from_registry,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- Write a test package with annotated types ---
|
|
19
|
+
|
|
20
|
+
def _write_test_types_package(tmp_path: Path) -> Path:
|
|
21
|
+
pkg = tmp_path / "test_codec_types"
|
|
22
|
+
pkg.mkdir()
|
|
23
|
+
(pkg / "__init__.py").write_text("")
|
|
24
|
+
(pkg / "types.py").write_text(textwrap.dedent("""\
|
|
25
|
+
import enum
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from semifun.di.model import Inject
|
|
28
|
+
|
|
29
|
+
_HTTP_STATUS = (200, 404, 403, 401, 500)
|
|
30
|
+
|
|
31
|
+
#::testing_tmsgpack_codec:FailureSeverity
|
|
32
|
+
class FailureSeverity(enum.IntEnum):
|
|
33
|
+
OK = 0
|
|
34
|
+
PROBLEM = 1
|
|
35
|
+
NOT_AUTHORIZED = 2
|
|
36
|
+
NOT_AUTHENTICATED = 3
|
|
37
|
+
UNEXPECTED = 4
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def http_status_code(self) -> int:
|
|
41
|
+
return _HTTP_STATUS[self]
|
|
42
|
+
|
|
43
|
+
def __str__(self):
|
|
44
|
+
return self.name
|
|
45
|
+
|
|
46
|
+
#::testing_tmsgpack_codec:Foo
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Foo:
|
|
49
|
+
x: str
|
|
50
|
+
y: int
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Dbh:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
#::testing_tmsgpack_codec:Bar
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Bar:
|
|
59
|
+
dbh: Inject[Dbh]
|
|
60
|
+
x: str
|
|
61
|
+
"""))
|
|
62
|
+
return pkg
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def test_types(tmp_path):
|
|
67
|
+
pkg = _write_test_types_package(tmp_path)
|
|
68
|
+
registry = create_registry_from_paths(
|
|
69
|
+
packages=[("test_codec_types", pkg)],
|
|
70
|
+
)
|
|
71
|
+
feature_map = _feature_map_from_registry(registry, "testing_tmsgpack_codec")
|
|
72
|
+
|
|
73
|
+
import test_codec_types.types as t
|
|
74
|
+
return t, feature_map
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _make_injectors_map():
|
|
78
|
+
_MISSING = object()
|
|
79
|
+
# documented-default: mirrors FeatureMap.__call__ — omitting `default`
|
|
80
|
+
# means "raise", which `None` cannot express.
|
|
81
|
+
def lookup(name, default=_MISSING): # documented-default
|
|
82
|
+
if default is not _MISSING:
|
|
83
|
+
return default
|
|
84
|
+
raise LookupError(name)
|
|
85
|
+
return lookup
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.fixture
|
|
89
|
+
def codec(test_types):
|
|
90
|
+
t, feature_map = test_types
|
|
91
|
+
di = DependencyInjector(injectors_map=_make_injectors_map(), seed_data={t.Dbh: t.Dbh()})
|
|
92
|
+
# testing-seam: pass feature_map callable instead of a string
|
|
93
|
+
return TmsgpackCodec(sort_keys=False, di=di, plugin_feature_type=feature_map), t
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture
|
|
97
|
+
def codec_no_di(test_types):
|
|
98
|
+
t, feature_map = test_types
|
|
99
|
+
# testing-seam: pass feature_map callable instead of a string
|
|
100
|
+
return TmsgpackCodec(sort_keys=False, di=NoDependencyInjector(),
|
|
101
|
+
plugin_feature_type=feature_map), t
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- Round-trip: plain dataclass ---
|
|
105
|
+
|
|
106
|
+
def test_round_trip_dataclass(codec):
|
|
107
|
+
codec, t = codec
|
|
108
|
+
foo = t.Foo(x='hello', y=123)
|
|
109
|
+
assert codec.decode(codec.encode(foo)) == foo
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- Round-trip: enum ---
|
|
113
|
+
|
|
114
|
+
def test_round_trip_enum(codec):
|
|
115
|
+
codec, t = codec
|
|
116
|
+
for severity in t.FailureSeverity:
|
|
117
|
+
assert codec.decode(codec.encode(severity)) == severity
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- Round-trip: dataclass with Inject[T] fields ---
|
|
121
|
+
|
|
122
|
+
def test_round_trip_dataclass_with_inject(codec):
|
|
123
|
+
codec, t = codec
|
|
124
|
+
bar = t.Bar(dbh=t.Dbh(), x='world')
|
|
125
|
+
data = codec.encode(bar)
|
|
126
|
+
decoded = codec.decode(data)
|
|
127
|
+
assert decoded.x == 'world'
|
|
128
|
+
assert isinstance(decoded.dbh, t.Dbh)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- NoDependencyInjector: plain types work without DI ---
|
|
132
|
+
|
|
133
|
+
def test_no_di_dataclass(codec_no_di):
|
|
134
|
+
codec, t = codec_no_di
|
|
135
|
+
foo = t.Foo(x='abc', y=42)
|
|
136
|
+
assert codec.decode(codec.encode(foo)) == foo
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_no_di_enum(codec_no_di):
|
|
140
|
+
codec, t = codec_no_di
|
|
141
|
+
baz = t.FailureSeverity.PROBLEM
|
|
142
|
+
assert codec.decode(codec.encode(baz)) == baz
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- with_seed_data ---
|
|
146
|
+
|
|
147
|
+
def test_with_seed_data_round_trip(codec):
|
|
148
|
+
codec, t = codec
|
|
149
|
+
other_dbh = t.Dbh()
|
|
150
|
+
codec2 = codec.with_seed_data({t.Dbh: other_dbh})
|
|
151
|
+
bar = t.Bar(dbh=t.Dbh(), x='test')
|
|
152
|
+
decoded = codec2.decode(codec2.encode(bar))
|
|
153
|
+
assert decoded.x == 'test'
|
|
154
|
+
assert decoded.dbh is other_dbh
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- Encoder/decoder caches are shared after with_seed_data ---
|
|
158
|
+
|
|
159
|
+
def test_with_seed_data_shares_caches(codec):
|
|
160
|
+
codec, t = codec
|
|
161
|
+
codec2 = codec.with_seed_data({t.Dbh: t.Dbh()})
|
|
162
|
+
assert codec2.encoder_cache is codec.encoder_cache
|
|
163
|
+
assert codec2.decoder_cache is codec.decoder_cache
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --- Inject[T] without a DependencyInjector ---
|
|
167
|
+
|
|
168
|
+
def test_inject_field_without_an_injector_fails_on_encode():
|
|
169
|
+
"""`NoDependencyInjector` does not recognise Inject[T], so the field is serialized.
|
|
170
|
+
|
|
171
|
+
Documented in [[tmsgpack:di-on-decode]]: exclusion depends on the
|
|
172
|
+
injector, so without one the codec tries to encode the injected value and
|
|
173
|
+
fails there — not on decode.
|
|
174
|
+
"""
|
|
175
|
+
from dataclasses import dataclass
|
|
176
|
+
|
|
177
|
+
from tmsgpack.codec import NoDependencyInjector, TmsgpackCodec
|
|
178
|
+
from semifun.di.model import Inject
|
|
179
|
+
|
|
180
|
+
class DbHandle:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class NeedsInjection:
|
|
185
|
+
dbh: Inject[DbHandle]
|
|
186
|
+
x: str
|
|
187
|
+
|
|
188
|
+
def feature_map(feature, default=None):
|
|
189
|
+
return {'NeedsInjection': NeedsInjection}.get(feature, default)
|
|
190
|
+
|
|
191
|
+
codec = TmsgpackCodec(
|
|
192
|
+
sort_keys=True,
|
|
193
|
+
di=NoDependencyInjector(),
|
|
194
|
+
plugin_feature_type=feature_map,
|
|
195
|
+
)
|
|
196
|
+
with pytest.raises(ValueError, match='Cannot encode this type'):
|
|
197
|
+
codec.encode(NeedsInjection(dbh=DbHandle(), x='hi'))
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Round-trip tests: every value encodes and decodes back to an equal value.
|
|
2
|
+
|
|
3
|
+
Corpora are parametrised by *group*, not by value. The generators below
|
|
4
|
+
produce several thousand values between them; one test item per value would be
|
|
5
|
+
slow to collect and would build test ids out of 2000-character strings. One
|
|
6
|
+
item per group keeps the output readable and still names the failing value.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from functools import cached_property
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from tmsgpack.api import EncodeDecode, basic_codec
|
|
17
|
+
from tmsgpack.core import TMsgpackError, __version__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- Value corpora ---
|
|
21
|
+
|
|
22
|
+
def _small_integers():
|
|
23
|
+
return range(-2000, 2000)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _integers():
|
|
27
|
+
"""Values either side of every power of two, both signs."""
|
|
28
|
+
return [
|
|
29
|
+
s * (2**e + d)
|
|
30
|
+
for e in range(63)
|
|
31
|
+
for d in range(-10, 10)
|
|
32
|
+
for s in (-1, +1)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _floats():
|
|
37
|
+
return [3.1415926]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _containers():
|
|
41
|
+
"""Strings, bytes and collections across a wide range of lengths."""
|
|
42
|
+
return [
|
|
43
|
+
v
|
|
44
|
+
for e in range(18)
|
|
45
|
+
for d in range(-2, 2)
|
|
46
|
+
for f in (1, 1 / 3)
|
|
47
|
+
for n in [int(f * 2**e + d)]
|
|
48
|
+
for v in [
|
|
49
|
+
"*" * n,
|
|
50
|
+
b"*" * n,
|
|
51
|
+
[12] * n,
|
|
52
|
+
(12,) * n,
|
|
53
|
+
{m: 3 * m + 1 for m in range(n)},
|
|
54
|
+
]
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _constants():
|
|
59
|
+
return [True, False, None]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _nested():
|
|
63
|
+
return [[1, 2, 3, 4, {'a': 'hello', 'b': ['world', 5, 6, 7]}]]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _large_integer():
|
|
67
|
+
return [1760628047033313535]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
BASIC_GROUPS = {
|
|
71
|
+
'small integers': _small_integers,
|
|
72
|
+
'integers': _integers,
|
|
73
|
+
'float': _floats,
|
|
74
|
+
'containers': _containers,
|
|
75
|
+
'constants': _constants,
|
|
76
|
+
'nested value': _nested,
|
|
77
|
+
'large integer': _large_integer,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize('group', sorted(BASIC_GROUPS))
|
|
82
|
+
def test_basic_codec_round_trip(group):
|
|
83
|
+
"""Every value in the group decodes back to an equal value."""
|
|
84
|
+
for value in BASIC_GROUPS[group]():
|
|
85
|
+
decoded = basic_codec.decode(basic_codec.encode(value))
|
|
86
|
+
assert decoded == value, (
|
|
87
|
+
f'{group}: a {type(value).__name__} did not round-trip'
|
|
88
|
+
+ (f' (length {len(value)})' if hasattr(value, '__len__') else '')
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- A codec that serializes registered dataclasses ---
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Foo:
|
|
96
|
+
x: int
|
|
97
|
+
y: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Unregistered:
|
|
102
|
+
z: int
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class MyCodec(EncodeDecode):
|
|
107
|
+
"""Minimal custom codec: serializes the dataclasses it is given."""
|
|
108
|
+
sort_keys = True
|
|
109
|
+
types: Sequence
|
|
110
|
+
|
|
111
|
+
encode_cache: dict = field(default_factory=dict, init=False, repr=False)
|
|
112
|
+
decode_cache: dict = field(default_factory=dict, init=False, repr=False)
|
|
113
|
+
|
|
114
|
+
@cached_property
|
|
115
|
+
def constructors(self):
|
|
116
|
+
return {t.__name__: t for t in self.types}
|
|
117
|
+
|
|
118
|
+
def prep_encode(self, value, target):
|
|
119
|
+
return [None, self, value]
|
|
120
|
+
|
|
121
|
+
def decode_codec(self, codec_type, source):
|
|
122
|
+
if codec_type is None:
|
|
123
|
+
return self
|
|
124
|
+
raise TMsgpackError(f'Unsupported codec_type: {codec_type}')
|
|
125
|
+
|
|
126
|
+
def encode_value(self, ectx):
|
|
127
|
+
t = type(ectx.value)
|
|
128
|
+
if t not in self.encode_cache:
|
|
129
|
+
type_name = self.type_to_name(t)
|
|
130
|
+
constructor = self.name_to_constructor(type_name)
|
|
131
|
+
args = self.constructor_to_args(constructor)
|
|
132
|
+
|
|
133
|
+
def encode_handler(ectx):
|
|
134
|
+
value = ectx.value
|
|
135
|
+
ectx.put_dict(type_name, {a: getattr(value, a) for a in args})
|
|
136
|
+
|
|
137
|
+
self.encode_cache[t] = encode_handler
|
|
138
|
+
self.encode_cache[t](ectx)
|
|
139
|
+
|
|
140
|
+
def decode_from_bytes(self, dctx):
|
|
141
|
+
raise TMsgpackError(f'No bytes extension defined: {dctx._type}')
|
|
142
|
+
|
|
143
|
+
def decode_from_list(self, dctx):
|
|
144
|
+
_type = dctx._type
|
|
145
|
+
if _type not in self.decode_cache:
|
|
146
|
+
constructor = self.name_to_constructor(_type)
|
|
147
|
+
|
|
148
|
+
def decode_handler(dctx):
|
|
149
|
+
return constructor(**dctx.take_dict())
|
|
150
|
+
|
|
151
|
+
self.decode_cache[_type] = decode_handler
|
|
152
|
+
return self.decode_cache[_type](dctx)
|
|
153
|
+
|
|
154
|
+
def type_to_name(self, _type):
|
|
155
|
+
return _type.__name__
|
|
156
|
+
|
|
157
|
+
def name_to_constructor(self, name):
|
|
158
|
+
if res := self.constructors.get(name, None):
|
|
159
|
+
return res
|
|
160
|
+
raise TMsgpackError(f'Unsupported type: {name}')
|
|
161
|
+
|
|
162
|
+
def constructor_to_args(self, constructor):
|
|
163
|
+
return inspect.signature(constructor).parameters.keys()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_custom_codec_round_trips_a_registered_dataclass():
|
|
167
|
+
codec = MyCodec(types=[Foo])
|
|
168
|
+
for value in [Foo(1, 2), Foo(2, 3)]:
|
|
169
|
+
assert codec.decode(codec.encode(value)) == value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_custom_codec_rejects_an_unregistered_type():
|
|
173
|
+
"""An unregistered dataclass raises rather than encoding to something wrong."""
|
|
174
|
+
codec = MyCodec(types=[Foo])
|
|
175
|
+
with pytest.raises(TMsgpackError):
|
|
176
|
+
codec.encode(Unregistered(z=1))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- Version ---
|
|
180
|
+
|
|
181
|
+
def test_version_matches_the_installed_metadata():
|
|
182
|
+
"""`__version__` is compiled into the extension; the metadata comes from pyproject.
|
|
183
|
+
|
|
184
|
+
A mismatch means the extension in use was built from a different version
|
|
185
|
+
than the installed package declares. `test_build_pyx.py` checks the same
|
|
186
|
+
version against the fragments the extension is generated from.
|
|
187
|
+
"""
|
|
188
|
+
from importlib.metadata import version
|
|
189
|
+
|
|
190
|
+
assert __version__ == version('tmsgpack')
|
|
File without changes
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
from typing import Any
|
|
1
|
+
from typing import Any
|
|
2
2
|
from dataclasses import dataclass
|
|
3
|
-
from tmsgpack.
|
|
4
|
-
from tmsgpack.
|
|
5
|
-
from tmsgpack.
|
|
3
|
+
from tmsgpack.core import EncodeBuffer, DecodeBuffer
|
|
4
|
+
from tmsgpack.core import ebuf_put_value, dbuf_take_value
|
|
5
|
+
from tmsgpack.core import TMsgpackError
|
|
6
6
|
|
|
7
7
|
class EncodeDecode:
|
|
8
8
|
def encode(self, value, target=None):
|
|
@@ -24,11 +24,20 @@ class EncodeDecode:
|
|
|
24
24
|
|
|
25
25
|
def dbuf_take_value(self, dbuf): return dbuf_take_value(self, dbuf)
|
|
26
26
|
|
|
27
|
+
def hash_to_bytes(self, value) -> bytes: # 16 bytes
|
|
28
|
+
import xxhash
|
|
29
|
+
assert self.sort_keys, 'hash_to_bytes: sort_keys is false --> unstable hash.'
|
|
30
|
+
return xxhash.xxh3_128_digest(self.encode(value))
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
def hash_to_str(self, value) -> str: # 22 chars
|
|
33
|
+
from base64 import urlsafe_b64encode
|
|
34
|
+
return urlsafe_b64encode(self.hash_to_bytes(value)).rstrip(b'=').decode('ascii')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
29
39
|
class BasicCodec(EncodeDecode):
|
|
30
40
|
sort_keys = True
|
|
31
|
-
use_cache = False
|
|
32
41
|
def prep_encode(self, value, target): return [None, self, value]
|
|
33
42
|
|
|
34
43
|
def decode_codec(self, codec_type, source):
|