tressed 0.0.1__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.
- tressed-0.0.1/PKG-INFO +88 -0
- tressed-0.0.1/README.md +78 -0
- tressed-0.0.1/pyproject.toml +39 -0
- tressed-0.0.1/setup.cfg +4 -0
- tressed-0.0.1/src/tressed/__init__.py +1 -0
- tressed-0.0.1/src/tressed/__version__.py +13 -0
- tressed-0.0.1/src/tressed/exceptions.py +13 -0
- tressed-0.0.1/src/tressed/loader/__init__.py +14 -0
- tressed-0.0.1/src/tressed/loader/loader.py +152 -0
- tressed-0.0.1/src/tressed/loader/loaders.py +80 -0
- tressed-0.0.1/src/tressed/loader/specializer.py +52 -0
- tressed-0.0.1/src/tressed/loader/specializers.py +219 -0
- tressed-0.0.1/src/tressed/loader/types.py +26 -0
- tressed-0.0.1/src/tressed/predicates.py +128 -0
- tressed-0.0.1/src/tressed/py.typed +0 -0
- tressed-0.0.1/src/tressed.egg-info/PKG-INFO +88 -0
- tressed-0.0.1/src/tressed.egg-info/SOURCES.txt +20 -0
- tressed-0.0.1/src/tressed.egg-info/dependency_links.txt +1 -0
- tressed-0.0.1/src/tressed.egg-info/top_level.txt +1 -0
- tressed-0.0.1/tests/test_gluetypes.py +36 -0
- tressed-0.0.1/tests/test_loader.py +188 -0
- tressed-0.0.1/tests/test_specializers.py +58 -0
tressed-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tressed
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Glue types together
|
|
5
|
+
Author-email: Thomas Cellerier <thomascellerier@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/thomascellerier/tressed
|
|
7
|
+
Project-URL: Repository, https://github.com/thomascellerier/tressed.git
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# tressed
|
|
12
|
+
|
|
13
|
+
Serialize and deserialize types, in any order.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```Python
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from tressed.loader import Loader
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SomeDataclass:
|
|
23
|
+
foo: str
|
|
24
|
+
bar: str = "bar"
|
|
25
|
+
baz: list[int] = field(default_factory=lambda: [1, 2, 3])
|
|
26
|
+
bar_bar: tuple[int, str] = field(metadata={"alias": "barBar"}, kw_only=True)
|
|
27
|
+
|
|
28
|
+
value = {
|
|
29
|
+
"foo": "foo",
|
|
30
|
+
"barBar": (2, "humbug"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
loader = Loader()
|
|
34
|
+
loaded = loader.load(value, SomeDataclass)
|
|
35
|
+
|
|
36
|
+
assert loaded == SomeDataclass(
|
|
37
|
+
foo="foo",
|
|
38
|
+
bar="bar",
|
|
39
|
+
baz=[1, 2, 3],
|
|
40
|
+
bar_bar=(2, "humbug"),
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Supported types
|
|
45
|
+
|
|
46
|
+
The following types are supported out of the box:
|
|
47
|
+
|
|
48
|
+
- bool
|
|
49
|
+
- int
|
|
50
|
+
- float
|
|
51
|
+
- str
|
|
52
|
+
- tuple[T1, ..., Tn], typing.Tuple[T1, ..., Tn]
|
|
53
|
+
- tuple[T, ...], typing.Tuple[T, ...] a.k.a homogeneous tuple
|
|
54
|
+
- list[T], typing.List[T]
|
|
55
|
+
- set[T], typing.Set[T]
|
|
56
|
+
- frozenset[T], typing.FrozenSet[T]
|
|
57
|
+
- dataclasses.dataclass
|
|
58
|
+
|
|
59
|
+
It is easy to add support for custom types as needed when creating a loader.
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
Using pip:
|
|
64
|
+
```bash
|
|
65
|
+
pip install tressed
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or using uv:
|
|
69
|
+
```bash
|
|
70
|
+
uv add tressed
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then just import tressed and get coding.
|
|
74
|
+
```Python
|
|
75
|
+
import tressed
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Goals
|
|
79
|
+
|
|
80
|
+
- Provide easy serialization and deserialization to and from built-in and standard library types.
|
|
81
|
+
- Provide easy way to add support for custom types.
|
|
82
|
+
- Provide easy yet powerful support for serializaiton and deserialization aliases.
|
|
83
|
+
- Pure python, no additional runtime dependencies beyond the standard library.
|
|
84
|
+
- Do as little work as possible to be suitable for CLIs, for example using lazy imports as much as possible.
|
|
85
|
+
|
|
86
|
+
### Stretch goals
|
|
87
|
+
- Add first class support for concurrent loading and dumping, with support for asyncio and threading, for example deserializing a list of M objects on N threads.
|
|
88
|
+
- Use code generation internally similarly to dataclasses. Instead of doing metaprogramming each time, generate an actual function for the type that does (de)serialization. Interpret the first time, compile the second, using source or ast module, and then reuse the compiled function.
|
tressed-0.0.1/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# tressed
|
|
2
|
+
|
|
3
|
+
Serialize and deserialize types, in any order.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```Python
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from tressed.loader import Loader
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SomeDataclass:
|
|
13
|
+
foo: str
|
|
14
|
+
bar: str = "bar"
|
|
15
|
+
baz: list[int] = field(default_factory=lambda: [1, 2, 3])
|
|
16
|
+
bar_bar: tuple[int, str] = field(metadata={"alias": "barBar"}, kw_only=True)
|
|
17
|
+
|
|
18
|
+
value = {
|
|
19
|
+
"foo": "foo",
|
|
20
|
+
"barBar": (2, "humbug"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
loader = Loader()
|
|
24
|
+
loaded = loader.load(value, SomeDataclass)
|
|
25
|
+
|
|
26
|
+
assert loaded == SomeDataclass(
|
|
27
|
+
foo="foo",
|
|
28
|
+
bar="bar",
|
|
29
|
+
baz=[1, 2, 3],
|
|
30
|
+
bar_bar=(2, "humbug"),
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Supported types
|
|
35
|
+
|
|
36
|
+
The following types are supported out of the box:
|
|
37
|
+
|
|
38
|
+
- bool
|
|
39
|
+
- int
|
|
40
|
+
- float
|
|
41
|
+
- str
|
|
42
|
+
- tuple[T1, ..., Tn], typing.Tuple[T1, ..., Tn]
|
|
43
|
+
- tuple[T, ...], typing.Tuple[T, ...] a.k.a homogeneous tuple
|
|
44
|
+
- list[T], typing.List[T]
|
|
45
|
+
- set[T], typing.Set[T]
|
|
46
|
+
- frozenset[T], typing.FrozenSet[T]
|
|
47
|
+
- dataclasses.dataclass
|
|
48
|
+
|
|
49
|
+
It is easy to add support for custom types as needed when creating a loader.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Using pip:
|
|
54
|
+
```bash
|
|
55
|
+
pip install tressed
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or using uv:
|
|
59
|
+
```bash
|
|
60
|
+
uv add tressed
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then just import tressed and get coding.
|
|
64
|
+
```Python
|
|
65
|
+
import tressed
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Goals
|
|
69
|
+
|
|
70
|
+
- Provide easy serialization and deserialization to and from built-in and standard library types.
|
|
71
|
+
- Provide easy way to add support for custom types.
|
|
72
|
+
- Provide easy yet powerful support for serializaiton and deserialization aliases.
|
|
73
|
+
- Pure python, no additional runtime dependencies beyond the standard library.
|
|
74
|
+
- Do as little work as possible to be suitable for CLIs, for example using lazy imports as much as possible.
|
|
75
|
+
|
|
76
|
+
### Stretch goals
|
|
77
|
+
- Add first class support for concurrent loading and dumping, with support for asyncio and threading, for example deserializing a list of M objects on N threads.
|
|
78
|
+
- Use code generation internally similarly to dataclasses. Instead of doing metaprogramming each time, generate an actual function for the type that does (de)serialization. Interpret the first time, compile the second, using source or ast module, and then reuse the compiled function.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tressed"
|
|
3
|
+
authors = [
|
|
4
|
+
{ name="Thomas Cellerier", email="thomascellerier@gmail.com" },
|
|
5
|
+
]
|
|
6
|
+
version = "0.0.1"
|
|
7
|
+
description = "Glue types together"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Homepage = "https://github.com/thomascellerier/tressed"
|
|
14
|
+
Repository = "https://github.com/thomascellerier/tressed.git"
|
|
15
|
+
|
|
16
|
+
[dependency-groups]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=9.0.1",
|
|
19
|
+
"pytest-benchmark>=5.2.3",
|
|
20
|
+
"pytest-mock>=3.15.1",
|
|
21
|
+
"ruff>=0.14.6",
|
|
22
|
+
"ty>=0.0.1a28",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.uv.build-backend]
|
|
26
|
+
module-name = "tressed"
|
|
27
|
+
module-root = "src"
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
src = ["src", "tests"]
|
|
31
|
+
|
|
32
|
+
[tool.ty.environment]
|
|
33
|
+
root = ["./src"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
pythonpath = [
|
|
37
|
+
"src"
|
|
38
|
+
]
|
|
39
|
+
|
tressed-0.0.1/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
__all__ = ["__version__"]
|
|
2
|
+
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
__version__: str
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name: str) -> str:
|
|
9
|
+
if name == "__version__":
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
|
|
12
|
+
return version("tressed")
|
|
13
|
+
raise AttributeError(f"Package 'tressed.__version__' has no attribute '{name}'")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
TYPE_CHECKING = False
|
|
2
|
+
if TYPE_CHECKING:
|
|
3
|
+
from tressed.loader.loader import Loader
|
|
4
|
+
|
|
5
|
+
__all__ = ["Loader"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name: str) -> type[Loader]:
|
|
9
|
+
if name == "Loader":
|
|
10
|
+
from tressed.loader.loader import Loader
|
|
11
|
+
|
|
12
|
+
return Loader
|
|
13
|
+
|
|
14
|
+
raise AttributeError(f"Package 'tressed.loader' has no attribute '{name}'")
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# TODO: Use python3.15+ lazy imports instead
|
|
2
|
+
# TODO: Switch to TypeForm https://peps.python.org/pep-0747/ once available
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tressed.exceptions import TressedTypeError, TressedValueError
|
|
6
|
+
|
|
7
|
+
TYPE_CHECKING = False
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any, Final
|
|
11
|
+
|
|
12
|
+
from tressed.loader.types import TypeLoaderFn, TypePath
|
|
13
|
+
from tressed.predicates import TypePredicate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Loader",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_type_loaders():
|
|
22
|
+
from tressed.loader.loaders import load_simple_scalar
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
bool: load_simple_scalar,
|
|
26
|
+
int: load_simple_scalar,
|
|
27
|
+
float: load_simple_scalar,
|
|
28
|
+
str: load_simple_scalar,
|
|
29
|
+
type(None): load_simple_scalar,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_type_mappers(specialize: bool) -> Mapping[TypePredicate, TypeLoaderFn]:
|
|
34
|
+
from tressed.loader.loaders import (
|
|
35
|
+
load_dataclass,
|
|
36
|
+
load_simple_collection,
|
|
37
|
+
load_tuple,
|
|
38
|
+
)
|
|
39
|
+
from tressed.predicates import (
|
|
40
|
+
is_dataclass_type,
|
|
41
|
+
is_frozenset_type,
|
|
42
|
+
is_homogeneous_tuple_type,
|
|
43
|
+
is_list_type,
|
|
44
|
+
is_set_type,
|
|
45
|
+
is_tuple_type,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
load_tuple_ = load_tuple
|
|
49
|
+
load_simple_collection_ = load_simple_collection
|
|
50
|
+
if specialize:
|
|
51
|
+
from tressed.loader.specializer import SpecializingLoader
|
|
52
|
+
from tressed.loader.specializers import (
|
|
53
|
+
specialize_load_simple_collection,
|
|
54
|
+
specialize_load_tuple,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
load_tuple_ = SpecializingLoader(load_tuple, specialize_load_tuple)
|
|
58
|
+
load_simple_collection_ = SpecializingLoader(
|
|
59
|
+
load_simple_collection, specialize_load_simple_collection
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Note that the order matters as some predicates match several types,
|
|
63
|
+
# put the most specific match first.
|
|
64
|
+
return {
|
|
65
|
+
is_homogeneous_tuple_type: load_simple_collection_,
|
|
66
|
+
is_tuple_type: load_tuple_,
|
|
67
|
+
is_list_type: load_simple_collection_,
|
|
68
|
+
is_set_type: load_simple_collection_,
|
|
69
|
+
is_frozenset_type: load_simple_collection_,
|
|
70
|
+
is_dataclass_type: load_dataclass,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
type Alias = str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Loader:
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
type_loaders: Mapping[type, TypeLoaderFn] | None = None,
|
|
81
|
+
type_mappers: Mapping[TypePredicate, TypeLoaderFn] | None = None,
|
|
82
|
+
enable_specialization: bool = False,
|
|
83
|
+
alias_field: str = "alias",
|
|
84
|
+
) -> None:
|
|
85
|
+
# Map a type to its loader
|
|
86
|
+
if type_loaders is None:
|
|
87
|
+
self._type_loaders: dict[type, TypeLoaderFn] = _default_type_loaders()
|
|
88
|
+
else:
|
|
89
|
+
self._type_loaders = dict(type_loaders)
|
|
90
|
+
|
|
91
|
+
# Mapping of type predicate to a loader
|
|
92
|
+
if type_mappers is None:
|
|
93
|
+
self._type_mappers: Mapping[TypePredicate, TypeLoaderFn] = (
|
|
94
|
+
_default_type_mappers(enable_specialization)
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
self._type_mappers = dict(type_mappers)
|
|
98
|
+
|
|
99
|
+
self._alias_field: Final = alias_field
|
|
100
|
+
# Mapping of (type, type_form, name) to alias
|
|
101
|
+
self._alias_cache: dict[tuple[type, TypePath, str], Alias] = {}
|
|
102
|
+
|
|
103
|
+
def _resolve_alias_no_cache[T](
|
|
104
|
+
self, type_form: type[T], type_path: TypePath, name: str
|
|
105
|
+
) -> Alias:
|
|
106
|
+
# TODO: Also have a type mapper for alias resolvers
|
|
107
|
+
if fields := getattr(type_form, "__dataclass_fields__", None):
|
|
108
|
+
field = fields[name]
|
|
109
|
+
alias = field.metadata.get(self._alias_field)
|
|
110
|
+
if alias is None:
|
|
111
|
+
return name
|
|
112
|
+
return alias
|
|
113
|
+
|
|
114
|
+
def _resolve_alias[T](
|
|
115
|
+
self, type_form: type[T], type_path: TypePath, name: str
|
|
116
|
+
) -> Alias:
|
|
117
|
+
cache_key = (type_form, type_path, name)
|
|
118
|
+
if (alias := self._alias_cache.get(cache_key)) is not None:
|
|
119
|
+
return alias
|
|
120
|
+
|
|
121
|
+
alias = self._resolve_alias_no_cache(type_form, type_path, name)
|
|
122
|
+
self._alias_cache[cache_key] = alias
|
|
123
|
+
return alias
|
|
124
|
+
|
|
125
|
+
def _load[T](self, value: Any, type_form: type[T], type_path: TypePath) -> T:
|
|
126
|
+
if (type_loader := self._type_loaders.get(type_form)) is None:
|
|
127
|
+
for type_predicate, type_loader in self._type_mappers.items():
|
|
128
|
+
if type_predicate(type_form):
|
|
129
|
+
break
|
|
130
|
+
else:
|
|
131
|
+
type_loader = None
|
|
132
|
+
if type_loader is None:
|
|
133
|
+
raise TressedTypeError(
|
|
134
|
+
f"Unhandled type form {type_form!r} at path {type_path!r} for value {value!r}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Cache lookup for next time
|
|
138
|
+
self._type_loaders[type_form] = type_loader
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
return type_loader(value, type_form, type_path, self) # type: ignore[call-non-callable]
|
|
142
|
+
except TressedValueError:
|
|
143
|
+
raise
|
|
144
|
+
except Exception as e:
|
|
145
|
+
error = TressedValueError(
|
|
146
|
+
f"Failed to load value {value!r} at path {type_path!r} into type form {type_form!r}"
|
|
147
|
+
)
|
|
148
|
+
error.add_note(f"{type(e)}: {e}")
|
|
149
|
+
raise error from e
|
|
150
|
+
|
|
151
|
+
def load[T](self, value: Any, type_form: type[T]) -> T:
|
|
152
|
+
return self._load(value, type_form, ())
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from tressed.exceptions import TressedValueError
|
|
2
|
+
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tressed.loader.types import LoaderProtocol, TypePath
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"load_simple_scalar",
|
|
11
|
+
"load_simple_collection",
|
|
12
|
+
"load_tuple",
|
|
13
|
+
"load_dataclass",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_simple_scalar[T](
|
|
18
|
+
value: Any, type_form: type[T], type_path: TypePath, loader: LoaderProtocol
|
|
19
|
+
) -> T:
|
|
20
|
+
if type(value) is type_form:
|
|
21
|
+
return value
|
|
22
|
+
raise TressedValueError(
|
|
23
|
+
f"Cannot to load value {value!r} at path {type_path!r} into {type_form.__name___}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_simple_collection[T](
|
|
28
|
+
value: Any, type_form: type[T], type_path: TypePath, loader: LoaderProtocol
|
|
29
|
+
) -> T:
|
|
30
|
+
from tressed.predicates import get_args, get_origin
|
|
31
|
+
|
|
32
|
+
origin = get_origin(type_form)
|
|
33
|
+
num_expected_args = 2 if origin is tuple else 1
|
|
34
|
+
args = get_args(type_form)
|
|
35
|
+
if origin is None or args is None or len(args) != num_expected_args:
|
|
36
|
+
raise TressedValueError(
|
|
37
|
+
f"Cannot to load value {value!r} at path {type_path!r}, "
|
|
38
|
+
f"{getattr(type_form, '__name__', type_form)} is not a homogeneous generic type"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
item_type = args[0]
|
|
42
|
+
return origin(
|
|
43
|
+
loader._load(item, item_type, (*type_path, pos))
|
|
44
|
+
for pos, item in enumerate(value)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_tuple[*Ts](
|
|
49
|
+
value: Any, type_form: tuple[*Ts], type_path: TypePath, loader: LoaderProtocol
|
|
50
|
+
) -> tuple[*Ts]:
|
|
51
|
+
from tressed.predicates import get_args, get_origin
|
|
52
|
+
|
|
53
|
+
origin = get_origin(type_form)
|
|
54
|
+
args = get_args(type_form)
|
|
55
|
+
if origin is None or args is None:
|
|
56
|
+
raise TressedValueError(
|
|
57
|
+
f"Cannot to load value {value!r} at path {type_path!r}, "
|
|
58
|
+
f"{getattr(type_form, '__name__', type_form)} is not a generic type"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return tuple(
|
|
62
|
+
loader._load(item, args[pos], (*type_path, pos))
|
|
63
|
+
for pos, item in enumerate(value)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_dataclass[T](
|
|
68
|
+
value: Any, type_form: type[T], type_path: TypePath, loader: LoaderProtocol
|
|
69
|
+
) -> T:
|
|
70
|
+
from dataclasses import MISSING, fields
|
|
71
|
+
|
|
72
|
+
loaded = {}
|
|
73
|
+
|
|
74
|
+
for field in fields(type_form):
|
|
75
|
+
field_name = field.name
|
|
76
|
+
alias = loader._resolve_alias(type_form, type_path, field_name)
|
|
77
|
+
if (field_value := value.get(alias, MISSING)) is not MISSING:
|
|
78
|
+
loaded[field_name] = field_value
|
|
79
|
+
|
|
80
|
+
return type_form(**loaded)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
TYPE_CHECKING = False
|
|
2
|
+
if TYPE_CHECKING:
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tressed.loader.types import LoaderProtocol, TypePath
|
|
6
|
+
|
|
7
|
+
__all__ = ["SpecializingLoader"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SpecializingLoader:
|
|
11
|
+
def __init__(self, loader, specializer) -> None:
|
|
12
|
+
self._loader = loader
|
|
13
|
+
self._specialized_loaders: dict = {}
|
|
14
|
+
self._specializer = specializer
|
|
15
|
+
self._threshold: int = 3
|
|
16
|
+
|
|
17
|
+
def __call__[T](
|
|
18
|
+
self,
|
|
19
|
+
value: Any,
|
|
20
|
+
type_form: type[T],
|
|
21
|
+
type_path: TypePath,
|
|
22
|
+
loader: LoaderProtocol,
|
|
23
|
+
) -> T:
|
|
24
|
+
key = (type_form, type_path)
|
|
25
|
+
specialized_loader = self._specialized_loaders.get(key)
|
|
26
|
+
if type(specialized_loader) is type(None) or type(specialized_loader) is int:
|
|
27
|
+
if type(specialized_loader) is int and specialized_loader > 0:
|
|
28
|
+
count = specialized_loader + 1
|
|
29
|
+
else:
|
|
30
|
+
count = 1
|
|
31
|
+
self._specialized_loaders[key] = count
|
|
32
|
+
specialized_loader = None
|
|
33
|
+
|
|
34
|
+
# Specialize!
|
|
35
|
+
if count > self._threshold:
|
|
36
|
+
specializer_fn_code = self._specializer(*key)
|
|
37
|
+
if specializer_fn_code is None:
|
|
38
|
+
# zero means we can't specialize this
|
|
39
|
+
self._specialized_loaders[key] = 0
|
|
40
|
+
else:
|
|
41
|
+
globals_ = {}
|
|
42
|
+
locals_ = {}
|
|
43
|
+
|
|
44
|
+
exec(specializer_fn_code, globals_, locals_)
|
|
45
|
+
|
|
46
|
+
specialized_loader = locals_["__specialized_fn"]
|
|
47
|
+
self._specialized_loaders[key] = specialized_loader
|
|
48
|
+
|
|
49
|
+
if specialized_loader is None:
|
|
50
|
+
return self._loader(value, type_form, type_path, loader)
|
|
51
|
+
else:
|
|
52
|
+
return specialized_loader(value, loader)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
TYPE_CHECKING = False
|
|
2
|
+
if TYPE_CHECKING:
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
from tressed.loader.types import TypeLoaderFn, TypePath, TypePathItem
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"specialize_load_tuple",
|
|
10
|
+
"specialize_load_simple_collection",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Codegen:
|
|
15
|
+
__slots__ = ("fn_name", "indent", "_parts", "_cached_code")
|
|
16
|
+
|
|
17
|
+
def __init__(self, fn_name: str = "__specialized_fn") -> None:
|
|
18
|
+
self.fn_name: Final = fn_name
|
|
19
|
+
self.indent: Final = " "
|
|
20
|
+
|
|
21
|
+
self._parts = []
|
|
22
|
+
self._cached_code: str | None = None
|
|
23
|
+
|
|
24
|
+
def code(self) -> str:
|
|
25
|
+
if cached_code := self._cached_code:
|
|
26
|
+
return cached_code
|
|
27
|
+
import io
|
|
28
|
+
|
|
29
|
+
builder = io.StringIO()
|
|
30
|
+
builder.write(f"def {self.fn_name}(value, loader):\n")
|
|
31
|
+
for line in self._parts:
|
|
32
|
+
builder.write(line)
|
|
33
|
+
builder.write("\n")
|
|
34
|
+
cached_code = builder.getvalue()
|
|
35
|
+
|
|
36
|
+
self._cached_code = cached_code
|
|
37
|
+
self._parts.clear()
|
|
38
|
+
return cached_code
|
|
39
|
+
|
|
40
|
+
def exec(self) -> TypeLoaderFn:
|
|
41
|
+
# NOTE: This relies on the source being executed being only trusted and validated inputs.
|
|
42
|
+
globals_ = {}
|
|
43
|
+
locals_ = {}
|
|
44
|
+
exec(self.code(), globals_, locals_)
|
|
45
|
+
return locals_[self.fn_name]
|
|
46
|
+
|
|
47
|
+
def _emit(self, part: str) -> None:
|
|
48
|
+
self._parts.append(part)
|
|
49
|
+
|
|
50
|
+
def _emit_indent(self, indent: int = 0) -> None:
|
|
51
|
+
self._emit(self.indent * (indent + 1))
|
|
52
|
+
|
|
53
|
+
def _emit_newline(self) -> None:
|
|
54
|
+
self._emit("\n")
|
|
55
|
+
|
|
56
|
+
def _emit_line(self, line: str, indent: int = 0) -> None:
|
|
57
|
+
self._emit_indent(indent)
|
|
58
|
+
self._emit(line)
|
|
59
|
+
self._emit_newline()
|
|
60
|
+
|
|
61
|
+
def emit_unpack_args(
|
|
62
|
+
self, ident: str, args: tuple[type, ...]
|
|
63
|
+
) -> tuple[tuple[str, type], ...]:
|
|
64
|
+
# TODO: Allocate identifiers!
|
|
65
|
+
items = tuple((f"_item_{pos}", type_form) for pos, type_form in enumerate(args))
|
|
66
|
+
match items:
|
|
67
|
+
case []:
|
|
68
|
+
pass
|
|
69
|
+
case [(item_ident, _)]:
|
|
70
|
+
self._emit_line(f"{item_ident}, = {ident}")
|
|
71
|
+
case [*_]:
|
|
72
|
+
self._emit_line(
|
|
73
|
+
f"{', '.join(item_ident for item_ident, _ in items)} = {ident}"
|
|
74
|
+
)
|
|
75
|
+
return items
|
|
76
|
+
|
|
77
|
+
def emit_load_fn(self, ident: str = "_load") -> str:
|
|
78
|
+
self._emit_line(f"{ident} = loader._load")
|
|
79
|
+
return ident
|
|
80
|
+
|
|
81
|
+
def emit_load(
|
|
82
|
+
self,
|
|
83
|
+
loader_ident: str,
|
|
84
|
+
ident: str,
|
|
85
|
+
type_form: type,
|
|
86
|
+
type_path: TypePath,
|
|
87
|
+
type_path_item: TypePathItem,
|
|
88
|
+
) -> str:
|
|
89
|
+
# TODO: Allocate identifiers!
|
|
90
|
+
loaded_ident = f"{ident}_loaded"
|
|
91
|
+
# TODO: Handle type forms!
|
|
92
|
+
self._emit_line(
|
|
93
|
+
f"{loaded_ident} = _load({ident}, {type_form.__qualname__}, {_type_path_repr(*type_path, type_path_item)})"
|
|
94
|
+
)
|
|
95
|
+
return loaded_ident
|
|
96
|
+
|
|
97
|
+
def emit_tuple(self, idents: Iterable[str], *, compact: bool = False) -> None:
|
|
98
|
+
self._emit("(")
|
|
99
|
+
if not compact:
|
|
100
|
+
self._emit_newline()
|
|
101
|
+
for ident in idents:
|
|
102
|
+
if not compact:
|
|
103
|
+
self._emit_indent(indent=1)
|
|
104
|
+
self._emit(f"{ident},")
|
|
105
|
+
if not compact:
|
|
106
|
+
self._emit_newline()
|
|
107
|
+
if not compact:
|
|
108
|
+
self._emit_indent()
|
|
109
|
+
self._emit(")")
|
|
110
|
+
self._emit_newline()
|
|
111
|
+
|
|
112
|
+
def emit_return(self) -> None:
|
|
113
|
+
self._emit_indent()
|
|
114
|
+
self._emit("return ")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def specialize_load_tuple[T](
|
|
118
|
+
type_form: type[T],
|
|
119
|
+
type_path: TypePath,
|
|
120
|
+
) -> str | None:
|
|
121
|
+
"""
|
|
122
|
+
Generate specialized function for given type at given path.
|
|
123
|
+
"""
|
|
124
|
+
from tressed.predicates import get_args
|
|
125
|
+
|
|
126
|
+
args = get_args(type_form)
|
|
127
|
+
assert args is not None
|
|
128
|
+
|
|
129
|
+
codegen = Codegen()
|
|
130
|
+
items = codegen.emit_unpack_args("value", args)
|
|
131
|
+
load_fn = codegen.emit_load_fn()
|
|
132
|
+
loaded = []
|
|
133
|
+
for pos, (item, arg_type_form) in enumerate(items):
|
|
134
|
+
loaded.append(codegen.emit_load(load_fn, item, arg_type_form, type_path, pos))
|
|
135
|
+
codegen.emit_return()
|
|
136
|
+
codegen.emit_tuple(loaded)
|
|
137
|
+
return codegen.code()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _type_form_repr(type_form) -> str:
|
|
141
|
+
from tressed.predicates import get_args, get_origin
|
|
142
|
+
|
|
143
|
+
origin = get_origin(type_form)
|
|
144
|
+
if origin is not None:
|
|
145
|
+
args = get_args(type_form)
|
|
146
|
+
assert args is not None
|
|
147
|
+
return _generic_type_repr(origin, args)
|
|
148
|
+
|
|
149
|
+
return type_form.__qualname__
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _generic_type_repr(origin: type, args: tuple[type, ...]) -> str:
|
|
153
|
+
if len(args) == 0:
|
|
154
|
+
args_str = "()"
|
|
155
|
+
else:
|
|
156
|
+
args_str = ", ".join(map(_type_form_repr, args))
|
|
157
|
+
return f"{origin.__qualname__}[{args_str}]"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Ident:
|
|
161
|
+
__slots__ = ("name",)
|
|
162
|
+
|
|
163
|
+
def __init__(self, name: str) -> None:
|
|
164
|
+
self.name = name
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _type_path_repr(*args: TypePathItem | Ident) -> str:
|
|
168
|
+
items = []
|
|
169
|
+
for arg in args:
|
|
170
|
+
match arg:
|
|
171
|
+
case Ident():
|
|
172
|
+
items.append(arg.name)
|
|
173
|
+
case _:
|
|
174
|
+
items.append(repr(arg))
|
|
175
|
+
|
|
176
|
+
match items:
|
|
177
|
+
case [item]:
|
|
178
|
+
# Special case for single item tuple
|
|
179
|
+
return f"({item},)"
|
|
180
|
+
case _:
|
|
181
|
+
return f"({', '.join(items)})"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def specialize_load_simple_collection[T](
|
|
185
|
+
type_form: type[T],
|
|
186
|
+
type_path: TypePath,
|
|
187
|
+
) -> str | None:
|
|
188
|
+
"""
|
|
189
|
+
Generate specialized function for given type at given path.
|
|
190
|
+
"""
|
|
191
|
+
from tressed.predicates import get_args, get_origin
|
|
192
|
+
|
|
193
|
+
origin = get_origin(type_form)
|
|
194
|
+
assert origin is not None
|
|
195
|
+
|
|
196
|
+
args = get_args(type_form)
|
|
197
|
+
assert args is not None
|
|
198
|
+
arg_type_form = args[0]
|
|
199
|
+
|
|
200
|
+
codegen = Codegen()
|
|
201
|
+
|
|
202
|
+
load_fn = codegen.emit_load_fn()
|
|
203
|
+
codegen.emit_return()
|
|
204
|
+
if origin is list:
|
|
205
|
+
open, close = "[", "]"
|
|
206
|
+
elif origin is set:
|
|
207
|
+
open, close = "{", "}"
|
|
208
|
+
elif origin is frozenset:
|
|
209
|
+
open, close = "({", "})"
|
|
210
|
+
else:
|
|
211
|
+
open, close = f"{origin.__qualname__}([", "])"
|
|
212
|
+
|
|
213
|
+
codegen._emit(f"""{open}
|
|
214
|
+
{load_fn}(item, {_type_form_repr(arg_type_form)}, {_type_path_repr(*type_path, Ident("pos"))})
|
|
215
|
+
for pos, item
|
|
216
|
+
in enumerate(value)
|
|
217
|
+
{close}
|
|
218
|
+
""")
|
|
219
|
+
return codegen.code()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
__all__ = []
|
|
2
|
+
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
type TypePathItem = str | int
|
|
9
|
+
type TypePath = tuple[TypePathItem, ...]
|
|
10
|
+
|
|
11
|
+
class LoaderProtocol(Protocol):
|
|
12
|
+
def _load[T](
|
|
13
|
+
self, value: Any, type_form: type[T], type_path: TypePath
|
|
14
|
+
) -> T: ...
|
|
15
|
+
def _resolve_alias[T](
|
|
16
|
+
self, type_form: type[T], type_path: TypePath, name: str
|
|
17
|
+
) -> str: ...
|
|
18
|
+
|
|
19
|
+
type TypeLoaderFn[T] = Callable[[Any, type[T], TypePath, LoaderProtocol], T]
|
|
20
|
+
|
|
21
|
+
__all__ += [
|
|
22
|
+
"TypePathItem",
|
|
23
|
+
"TypePath",
|
|
24
|
+
"LoaderProtocol",
|
|
25
|
+
"TypeLoaderFn",
|
|
26
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type predicates.
|
|
3
|
+
|
|
4
|
+
Do not import modules if not absolutely required. Instead, take advantage of the fact
|
|
5
|
+
that a type from a module can only be instantiated if it already has been imported.
|
|
6
|
+
|
|
7
|
+
For example to check if a type is an instance of foo.Bar, do not do:
|
|
8
|
+
|
|
9
|
+
def is_foo_bar[T: type](type_form: T) -> bool:
|
|
10
|
+
import foo
|
|
11
|
+
|
|
12
|
+
return subclass(t, foo.Bar)
|
|
13
|
+
|
|
14
|
+
But instead:
|
|
15
|
+
|
|
16
|
+
def is_foo_bar[T: type](type_form: T) -> bool:
|
|
17
|
+
if foo := sys.modules.get("foo"):
|
|
18
|
+
retur issubclass(t, foo.Bar)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
TYPE_CHECKING = False
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"get_origin",
|
|
30
|
+
"get_args",
|
|
31
|
+
"is_tuple_type",
|
|
32
|
+
"is_homogeneous_tuple_type",
|
|
33
|
+
"is_list_type",
|
|
34
|
+
"is_set_type",
|
|
35
|
+
"is_frozenset_type",
|
|
36
|
+
"is_dataclass_type",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
# TODO: Use actual typing.TypeForm once it has made its way into python.
|
|
41
|
+
# Experimental support is coming in mypy 1.19, the PEP (747) is not accepted yet as of the time of writing.
|
|
42
|
+
type TypeForm = Any
|
|
43
|
+
type TypePredicate[T: TypeForm] = Callable[[T], bool]
|
|
44
|
+
|
|
45
|
+
__all__ += [
|
|
46
|
+
"TypeForm",
|
|
47
|
+
"TypePredicate",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_origin(type_form: TypeForm) -> type | None:
|
|
52
|
+
# Avoid importing typing module if possible.
|
|
53
|
+
# TODO: Handle annotated, generic etc...
|
|
54
|
+
return getattr(type_form, "__origin__", None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_args(type_form: TypeForm) -> tuple[type, ...] | None:
|
|
58
|
+
# Avoid importing typing module if possible.
|
|
59
|
+
# TODO: Handle annotated, generic etc...
|
|
60
|
+
return getattr(type_form, "__args__", None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_homogeneous_tuple_type(type_form: TypeForm) -> bool:
|
|
64
|
+
"""
|
|
65
|
+
The given type form matches tuple[T, ...] or typing.Tuple[T, ...]
|
|
66
|
+
"""
|
|
67
|
+
origin = get_origin(type_form)
|
|
68
|
+
if origin is None:
|
|
69
|
+
return False
|
|
70
|
+
if origin is tuple or (
|
|
71
|
+
(typing := sys.modules.get("typing")) and origin is typing.Tuple
|
|
72
|
+
):
|
|
73
|
+
args = get_args(type_form)
|
|
74
|
+
if args is None:
|
|
75
|
+
return False
|
|
76
|
+
return len(args) == 2 and args[1] == Ellipsis
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_tuple_type(type_form: TypeForm) -> bool:
|
|
81
|
+
origin = get_origin(type_form)
|
|
82
|
+
if origin is None:
|
|
83
|
+
return False
|
|
84
|
+
if origin is tuple:
|
|
85
|
+
return True
|
|
86
|
+
if typing := sys.modules.get("typing"):
|
|
87
|
+
return origin is typing.Tuple
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_list_type(type_form: TypeForm) -> bool:
|
|
92
|
+
origin = get_origin(type_form)
|
|
93
|
+
if origin is None:
|
|
94
|
+
return False
|
|
95
|
+
if origin is list:
|
|
96
|
+
return True
|
|
97
|
+
if typing := sys.modules.get("typing"):
|
|
98
|
+
return origin is typing.List
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_set_type(type_form: TypeForm) -> bool:
|
|
103
|
+
origin = get_origin(type_form)
|
|
104
|
+
if origin is None:
|
|
105
|
+
return False
|
|
106
|
+
if origin is set:
|
|
107
|
+
return True
|
|
108
|
+
if typing := sys.modules.get("typing"):
|
|
109
|
+
return origin is typing.Set
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_frozenset_type(type_form: TypeForm) -> bool:
|
|
114
|
+
origin = get_origin(type_form)
|
|
115
|
+
if origin is None:
|
|
116
|
+
return False
|
|
117
|
+
if origin is frozenset:
|
|
118
|
+
return True
|
|
119
|
+
if typing := sys.modules.get("typing"):
|
|
120
|
+
return origin is typing.FrozenSet
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_dataclass_type(type_form: TypeForm) -> bool:
|
|
125
|
+
if dataclasses := sys.modules.get("dataclasses"):
|
|
126
|
+
# We want to match only dataclass types, not instances
|
|
127
|
+
return dataclasses.is_dataclass(type_form) and isinstance(type_form, type)
|
|
128
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tressed
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Glue types together
|
|
5
|
+
Author-email: Thomas Cellerier <thomascellerier@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/thomascellerier/tressed
|
|
7
|
+
Project-URL: Repository, https://github.com/thomascellerier/tressed.git
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# tressed
|
|
12
|
+
|
|
13
|
+
Serialize and deserialize types, in any order.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```Python
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from tressed.loader import Loader
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SomeDataclass:
|
|
23
|
+
foo: str
|
|
24
|
+
bar: str = "bar"
|
|
25
|
+
baz: list[int] = field(default_factory=lambda: [1, 2, 3])
|
|
26
|
+
bar_bar: tuple[int, str] = field(metadata={"alias": "barBar"}, kw_only=True)
|
|
27
|
+
|
|
28
|
+
value = {
|
|
29
|
+
"foo": "foo",
|
|
30
|
+
"barBar": (2, "humbug"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
loader = Loader()
|
|
34
|
+
loaded = loader.load(value, SomeDataclass)
|
|
35
|
+
|
|
36
|
+
assert loaded == SomeDataclass(
|
|
37
|
+
foo="foo",
|
|
38
|
+
bar="bar",
|
|
39
|
+
baz=[1, 2, 3],
|
|
40
|
+
bar_bar=(2, "humbug"),
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Supported types
|
|
45
|
+
|
|
46
|
+
The following types are supported out of the box:
|
|
47
|
+
|
|
48
|
+
- bool
|
|
49
|
+
- int
|
|
50
|
+
- float
|
|
51
|
+
- str
|
|
52
|
+
- tuple[T1, ..., Tn], typing.Tuple[T1, ..., Tn]
|
|
53
|
+
- tuple[T, ...], typing.Tuple[T, ...] a.k.a homogeneous tuple
|
|
54
|
+
- list[T], typing.List[T]
|
|
55
|
+
- set[T], typing.Set[T]
|
|
56
|
+
- frozenset[T], typing.FrozenSet[T]
|
|
57
|
+
- dataclasses.dataclass
|
|
58
|
+
|
|
59
|
+
It is easy to add support for custom types as needed when creating a loader.
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
Using pip:
|
|
64
|
+
```bash
|
|
65
|
+
pip install tressed
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or using uv:
|
|
69
|
+
```bash
|
|
70
|
+
uv add tressed
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then just import tressed and get coding.
|
|
74
|
+
```Python
|
|
75
|
+
import tressed
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Goals
|
|
79
|
+
|
|
80
|
+
- Provide easy serialization and deserialization to and from built-in and standard library types.
|
|
81
|
+
- Provide easy way to add support for custom types.
|
|
82
|
+
- Provide easy yet powerful support for serializaiton and deserialization aliases.
|
|
83
|
+
- Pure python, no additional runtime dependencies beyond the standard library.
|
|
84
|
+
- Do as little work as possible to be suitable for CLIs, for example using lazy imports as much as possible.
|
|
85
|
+
|
|
86
|
+
### Stretch goals
|
|
87
|
+
- Add first class support for concurrent loading and dumping, with support for asyncio and threading, for example deserializing a list of M objects on N threads.
|
|
88
|
+
- Use code generation internally similarly to dataclasses. Instead of doing metaprogramming each time, generate an actual function for the type that does (de)serialization. Interpret the first time, compile the second, using source or ast module, and then reuse the compiled function.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/tressed/__init__.py
|
|
4
|
+
src/tressed/__version__.py
|
|
5
|
+
src/tressed/exceptions.py
|
|
6
|
+
src/tressed/predicates.py
|
|
7
|
+
src/tressed/py.typed
|
|
8
|
+
src/tressed.egg-info/PKG-INFO
|
|
9
|
+
src/tressed.egg-info/SOURCES.txt
|
|
10
|
+
src/tressed.egg-info/dependency_links.txt
|
|
11
|
+
src/tressed.egg-info/top_level.txt
|
|
12
|
+
src/tressed/loader/__init__.py
|
|
13
|
+
src/tressed/loader/loader.py
|
|
14
|
+
src/tressed/loader/loaders.py
|
|
15
|
+
src/tressed/loader/specializer.py
|
|
16
|
+
src/tressed/loader/specializers.py
|
|
17
|
+
src/tressed/loader/types.py
|
|
18
|
+
tests/test_gluetypes.py
|
|
19
|
+
tests/test_loader.py
|
|
20
|
+
tests/test_specializers.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tressed
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_import_tressed(tmp_path: Path) -> None:
|
|
7
|
+
"""
|
|
8
|
+
Make sure tressed uses lazy imports by default.
|
|
9
|
+
Note that this is not fully reliable as pytest itself already loads a ton of modules.
|
|
10
|
+
"""
|
|
11
|
+
prog = """\
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
def audit(event: str, args: tuple) -> None:
|
|
15
|
+
match event:
|
|
16
|
+
case "import":
|
|
17
|
+
print(args[0])
|
|
18
|
+
|
|
19
|
+
sys.addaudithook(audit)
|
|
20
|
+
|
|
21
|
+
import tressed
|
|
22
|
+
"""
|
|
23
|
+
prog_path = tmp_path / "prog.py"
|
|
24
|
+
prog_path.write_text(prog)
|
|
25
|
+
|
|
26
|
+
# Use audit hooks to detect imports, audit hooks cannot be removed so do it in a sub-process
|
|
27
|
+
src_path = Path(__file__).parent.parent / "src"
|
|
28
|
+
process = subprocess.run(
|
|
29
|
+
[sys.executable, "-S", prog_path],
|
|
30
|
+
check=True,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
env={"PYTHONPATH": str(src_path)},
|
|
33
|
+
)
|
|
34
|
+
loaded_modules = set(process.stdout.decode("utf-8").splitlines(keepends=False))
|
|
35
|
+
|
|
36
|
+
assert loaded_modules == {"tressed"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import gc
|
|
2
|
+
import sys
|
|
3
|
+
from unittest.mock import call
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pytest_benchmark.fixture import BenchmarkFixture
|
|
7
|
+
from pytest_mock import MockerFixture
|
|
8
|
+
|
|
9
|
+
from tressed.exceptions import TressedTypeError, TressedValueError
|
|
10
|
+
from tressed.loader import Loader
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_load_simple_scalar() -> None:
|
|
14
|
+
loader = Loader()
|
|
15
|
+
assert loader.load(1, int) == 1
|
|
16
|
+
assert loader.load(1.1, float) == 1.1
|
|
17
|
+
assert loader.load(True, bool) is True
|
|
18
|
+
assert loader.load(False, bool) is False
|
|
19
|
+
assert loader.load("foo", str) == "foo"
|
|
20
|
+
assert loader.load(None, type(None)) is None
|
|
21
|
+
|
|
22
|
+
with pytest.raises(TressedValueError):
|
|
23
|
+
loader.load(1, str)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_load_list() -> None:
|
|
27
|
+
loader = Loader()
|
|
28
|
+
assert loader.load([1, 2, 3], list[int]) == [1, 2, 3]
|
|
29
|
+
|
|
30
|
+
with pytest.raises(TressedTypeError):
|
|
31
|
+
loader.load(["foo", "bar"], list)
|
|
32
|
+
|
|
33
|
+
with pytest.raises(TressedValueError):
|
|
34
|
+
loader.load(["foo", "bar"], list[int])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_load_set() -> None:
|
|
38
|
+
loader = Loader()
|
|
39
|
+
loaded = loader.load([1, 2, 3, 2], set[int])
|
|
40
|
+
assert type(loaded) is set
|
|
41
|
+
assert loaded == {1, 2, 3}
|
|
42
|
+
|
|
43
|
+
with pytest.raises(TressedTypeError):
|
|
44
|
+
loader.load(["foo", "bar"], set)
|
|
45
|
+
|
|
46
|
+
with pytest.raises(TressedValueError):
|
|
47
|
+
loader.load(["foo", "bar"], set[int])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_load_frozenset() -> None:
|
|
51
|
+
loader = Loader()
|
|
52
|
+
loaded = loader.load([1, 2, 3, 2], frozenset[int])
|
|
53
|
+
assert type(loaded) is frozenset
|
|
54
|
+
assert loaded == frozenset({1, 2, 3})
|
|
55
|
+
|
|
56
|
+
with pytest.raises(TressedTypeError):
|
|
57
|
+
loader.load(["foo", "bar"], frozenset)
|
|
58
|
+
|
|
59
|
+
with pytest.raises(TressedValueError):
|
|
60
|
+
loader.load(["foo", "bar"], frozenset[int])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_load_homogeneous_tuple() -> None:
|
|
64
|
+
loader = Loader()
|
|
65
|
+
assert loader.load([1, 2, 3], tuple[int, ...]) == (1, 2, 3)
|
|
66
|
+
|
|
67
|
+
with pytest.raises(TressedTypeError):
|
|
68
|
+
loader.load(["foo", "bar"], tuple)
|
|
69
|
+
|
|
70
|
+
with pytest.raises(TressedValueError):
|
|
71
|
+
loader.load(["foo", "bar"], tuple[int, ...])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_load_tuple() -> None:
|
|
75
|
+
loader = Loader()
|
|
76
|
+
assert loader.load([], tuple[()]) == ()
|
|
77
|
+
assert loader.load([1], tuple[int]) == (1,)
|
|
78
|
+
assert loader.load([1, True], tuple[int, bool]) == (1, True)
|
|
79
|
+
assert loader.load([1, True, 1.1], tuple[int, bool, float]) == (1, True, 1.1)
|
|
80
|
+
|
|
81
|
+
with pytest.raises(TressedValueError):
|
|
82
|
+
loader.load([1, True, "foo"], tuple[int, bool, float])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_load_tuple_benchmark(benchmark: BenchmarkFixture) -> None:
|
|
86
|
+
loader = Loader()
|
|
87
|
+
benchmark(loader.load, [1, True, 1.1], tuple[int, bool, float])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_load_tuple_specialized_benchmark(benchmark: BenchmarkFixture) -> None:
|
|
91
|
+
loader = Loader(enable_specialization=True)
|
|
92
|
+
benchmark(loader.load, [1, True, 1.1], tuple[int, bool, float])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_load_set_benchmark(benchmark: BenchmarkFixture) -> None:
|
|
96
|
+
loader = Loader()
|
|
97
|
+
benchmark(loader.load, [[1, "two", 3.3]] * 30, set[tuple[int, str, float]])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_load_set_specialized_benchmark(benchmark: BenchmarkFixture) -> None:
|
|
101
|
+
loader = Loader(enable_specialization=True)
|
|
102
|
+
benchmark(loader.load, [[1, "two", 3.3]] * 30, set[tuple[int, str, float]])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_load_unknown_type() -> None:
|
|
106
|
+
# Loading an unknown type will go through all the predicates,
|
|
107
|
+
# make sure the predicates do not load any additional modules.
|
|
108
|
+
if "dataclasses" in sys.modules:
|
|
109
|
+
del sys.modules["dataclasses"]
|
|
110
|
+
gc.collect()
|
|
111
|
+
|
|
112
|
+
modules_before = frozenset(sys.modules.keys())
|
|
113
|
+
assert "dataclasses" not in modules_before
|
|
114
|
+
|
|
115
|
+
loader = Loader()
|
|
116
|
+
|
|
117
|
+
class MadeupType:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
with pytest.raises(TressedTypeError):
|
|
121
|
+
loader.load(1, MadeupType)
|
|
122
|
+
|
|
123
|
+
modules_after = frozenset(sys.modules.keys())
|
|
124
|
+
new_modules = frozenset(
|
|
125
|
+
{
|
|
126
|
+
module
|
|
127
|
+
for module in modules_after - modules_after
|
|
128
|
+
# Ignore internal modules
|
|
129
|
+
if not module.startswith("tressed.")
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
assert new_modules == frozenset()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_load_dataclass(mocker: MockerFixture) -> None:
|
|
136
|
+
from dataclasses import dataclass, field
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class SomeDataclass:
|
|
140
|
+
foo: str
|
|
141
|
+
bar: str = "bar"
|
|
142
|
+
baz: list[int] = field(default_factory=lambda: [1, 2, 3])
|
|
143
|
+
bar_bar: tuple[int, str] = field(metadata={"alias": "barBar"}, kw_only=True)
|
|
144
|
+
|
|
145
|
+
value = {
|
|
146
|
+
"foo": "foo",
|
|
147
|
+
"barBar": (2, "humbug"),
|
|
148
|
+
}
|
|
149
|
+
expected = SomeDataclass(
|
|
150
|
+
foo="foo",
|
|
151
|
+
bar="bar",
|
|
152
|
+
baz=[1, 2, 3],
|
|
153
|
+
bar_bar=(2, "humbug"),
|
|
154
|
+
)
|
|
155
|
+
expected_cache = {
|
|
156
|
+
(SomeDataclass, (), "foo"): "foo",
|
|
157
|
+
(SomeDataclass, (), "bar"): "bar",
|
|
158
|
+
(SomeDataclass, (), "baz"): "baz",
|
|
159
|
+
(SomeDataclass, (), "bar_bar"): "barBar",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
loader = Loader()
|
|
163
|
+
|
|
164
|
+
spy_resolve_alias = mocker.spy(loader, loader._resolve_alias.__name__)
|
|
165
|
+
spy_resolve_alias_no_cache = mocker.spy(
|
|
166
|
+
loader, loader._resolve_alias_no_cache.__name__
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
assert loader.load(value, SomeDataclass) == expected
|
|
170
|
+
|
|
171
|
+
# First, call make sure that the cache is created
|
|
172
|
+
assert loader._alias_cache == expected_cache
|
|
173
|
+
|
|
174
|
+
expected_calls = [
|
|
175
|
+
call(SomeDataclass, (), "foo"),
|
|
176
|
+
call(SomeDataclass, (), "bar"),
|
|
177
|
+
call(SomeDataclass, (), "baz"),
|
|
178
|
+
call(SomeDataclass, (), "bar_bar"),
|
|
179
|
+
]
|
|
180
|
+
assert spy_resolve_alias.call_args_list == expected_calls
|
|
181
|
+
assert spy_resolve_alias_no_cache.call_args_list == expected_calls
|
|
182
|
+
|
|
183
|
+
assert loader.load(value, SomeDataclass) == expected
|
|
184
|
+
|
|
185
|
+
# Second, call make sure that the cache is used created
|
|
186
|
+
assert loader._alias_cache == expected_cache
|
|
187
|
+
assert spy_resolve_alias.call_args_list == expected_calls * 2
|
|
188
|
+
assert spy_resolve_alias_no_cache.call_args_list == expected_calls
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
def test_specialize_load_tuple() -> None:
|
|
2
|
+
from tressed.loader import Loader
|
|
3
|
+
from tressed.loader.specializers import specialize_load_tuple
|
|
4
|
+
|
|
5
|
+
code = specialize_load_tuple(tuple[int, float, str], ("foo", 1))
|
|
6
|
+
assert (
|
|
7
|
+
code
|
|
8
|
+
== """\
|
|
9
|
+
def __specialized_fn(value, loader):
|
|
10
|
+
_item_0, _item_1, _item_2 = value
|
|
11
|
+
_load = loader._load
|
|
12
|
+
_item_0_loaded = _load(_item_0, int, ('foo', 1, 0))
|
|
13
|
+
_item_1_loaded = _load(_item_1, float, ('foo', 1, 1))
|
|
14
|
+
_item_2_loaded = _load(_item_2, str, ('foo', 1, 2))
|
|
15
|
+
return (
|
|
16
|
+
_item_0_loaded,
|
|
17
|
+
_item_1_loaded,
|
|
18
|
+
_item_2_loaded,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
)
|
|
23
|
+
loader = Loader()
|
|
24
|
+
globals_ = {}
|
|
25
|
+
locals_ = {}
|
|
26
|
+
exec(code, globals_, locals_)
|
|
27
|
+
specialized_fn = locals_["__specialized_fn"]
|
|
28
|
+
assert specialized_fn([1, 1.1, "foobar"], loader) == (1, 1.1, "foobar")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_specialize_load_simple_collection() -> None:
|
|
32
|
+
from tressed.loader import Loader
|
|
33
|
+
from tressed.loader.specializers import specialize_load_simple_collection
|
|
34
|
+
|
|
35
|
+
code = specialize_load_simple_collection(
|
|
36
|
+
set[tuple[int, str, float]], ("foo", 0, "bar")
|
|
37
|
+
)
|
|
38
|
+
assert (
|
|
39
|
+
code
|
|
40
|
+
== """\
|
|
41
|
+
def __specialized_fn(value, loader):
|
|
42
|
+
_load = loader._load
|
|
43
|
+
return {
|
|
44
|
+
_load(item, tuple[int, str, float], ('foo', 0, 'bar', pos))
|
|
45
|
+
for pos, item
|
|
46
|
+
in enumerate(value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
)
|
|
51
|
+
loader = Loader()
|
|
52
|
+
globals_ = {}
|
|
53
|
+
locals_ = {}
|
|
54
|
+
exec(code, globals_, locals_)
|
|
55
|
+
specialized_fn = locals_["__specialized_fn"]
|
|
56
|
+
assert specialized_fn(
|
|
57
|
+
[[1, "two", 3.3], [4, "five", 5.5], [1, "two", 3.3]], loader
|
|
58
|
+
) == {(1, "two", 3.3), (4, "five", 5.5)}
|