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.
Files changed (39) hide show
  1. {tmsgpack-0.2.22 → tmsgpack-0.3.0}/MANIFEST.in +2 -2
  2. tmsgpack-0.3.0/PKG-INFO +11 -0
  3. tmsgpack-0.3.0/build_pyx.py +67 -0
  4. tmsgpack-0.3.0/pyproject.toml +37 -0
  5. tmsgpack-0.3.0/setup.cfg +4 -0
  6. {tmsgpack-0.2.22 → tmsgpack-0.3.0}/setup.py +6 -3
  7. tmsgpack-0.3.0/tests/test_build_pyx.py +65 -0
  8. tmsgpack-0.3.0/tests/test_codec.py +197 -0
  9. tmsgpack-0.3.0/tests/test_round_trip.py +190 -0
  10. tmsgpack-0.3.0/tmsgpack/__init__.py +0 -0
  11. {tmsgpack-0.2.22 → tmsgpack-0.3.0}/tmsgpack/api.py +15 -6
  12. tmsgpack-0.3.0/tmsgpack/codec.py +124 -0
  13. tmsgpack-0.3.0/tmsgpack/core.c +33862 -0
  14. tmsgpack-0.2.22/tmsgpack/cython.pyx → tmsgpack-0.3.0/tmsgpack/core.pyx +2 -2
  15. tmsgpack-0.3.0/tmsgpack/src-parts/01-imports +10 -0
  16. tmsgpack-0.3.0/tmsgpack/src-parts/02-constants +53 -0
  17. tmsgpack-0.3.0/tmsgpack/src-parts/03-encode-fn +153 -0
  18. tmsgpack-0.3.0/tmsgpack/src-parts/04-decode-fn +140 -0
  19. tmsgpack-0.3.0/tmsgpack/src-parts/05-encode-buffer +66 -0
  20. tmsgpack-0.3.0/tmsgpack/src-parts/06-decode-buffer +72 -0
  21. tmsgpack-0.3.0/tmsgpack/src-parts/07-encode-buffer-slow +33 -0
  22. tmsgpack-0.3.0/tmsgpack/src-parts/08-decode-buffer-slow +37 -0
  23. tmsgpack-0.3.0/tmsgpack/src-parts/09-exceptions +1 -0
  24. tmsgpack-0.3.0/tmsgpack.egg-info/PKG-INFO +11 -0
  25. tmsgpack-0.3.0/tmsgpack.egg-info/SOURCES.txt +27 -0
  26. tmsgpack-0.3.0/tmsgpack.egg-info/requires.txt +2 -0
  27. tmsgpack-0.2.22/Automatic-Version +0 -1
  28. tmsgpack-0.2.22/COPYING +0 -2
  29. tmsgpack-0.2.22/LICENSE +0 -5
  30. tmsgpack-0.2.22/PKG-INFO +0 -29
  31. tmsgpack-0.2.22/pyproject.toml +0 -7
  32. tmsgpack-0.2.22/setup.cfg +0 -30
  33. tmsgpack-0.2.22/tmsgpack/__init__.py +0 -14
  34. tmsgpack-0.2.22/tmsgpack/cython.c +0 -34863
  35. tmsgpack-0.2.22/tmsgpack.egg-info/PKG-INFO +0 -29
  36. tmsgpack-0.2.22/tmsgpack.egg-info/SOURCES.txt +0 -16
  37. {tmsgpack-0.2.22 → tmsgpack-0.3.0}/README.md +0 -0
  38. {tmsgpack-0.2.22 → tmsgpack-0.3.0}/tmsgpack.egg-info/dependency_links.txt +0 -0
  39. {tmsgpack-0.2.22 → tmsgpack-0.3.0}/tmsgpack.egg-info/top_level.txt +0 -0
@@ -1,5 +1,5 @@
1
1
  include setup.py
2
- include COPYING
3
- include LICENSE
4
2
  include README.md
5
3
  recursive-include tmsgpack *.pyx *.c *.h
4
+ recursive-include tmsgpack/src-parts *
5
+ include build_pyx.py
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
- # Automatically find all .pyx files in src/
6
- pyx_files = glob.glob("tmsgpack/*.pyx")
7
+ sys.path.insert(0, os.getcwd())
8
+ from build_pyx import PYX_PATH, write_pyx
7
9
 
8
- print("Found packages:", find_packages())
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, Tuple
1
+ from typing import Any
2
2
  from dataclasses import dataclass
3
- from tmsgpack.cython import EncodeBuffer, DecodeBuffer
4
- from tmsgpack.cython import ebuf_put_value, dbuf_take_value
5
- from tmsgpack.cython import TMsgpackError
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
- @dataclass
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):