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 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.
@@ -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
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,13 @@
1
+ __all__ = ["TressedError", "TressedTypeError", "TressedValueError"]
2
+
3
+
4
+ class TressedError(Exception):
5
+ pass
6
+
7
+
8
+ class TressedTypeError(TressedError, TypeError):
9
+ pass
10
+
11
+
12
+ class TressedValueError(TressedError, ValueError):
13
+ pass
@@ -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
+ 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)}