tressed 0.0.1__py3-none-any.whl
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/__init__.py +1 -0
- tressed/__version__.py +13 -0
- tressed/exceptions.py +13 -0
- tressed/loader/__init__.py +14 -0
- tressed/loader/loader.py +152 -0
- tressed/loader/loaders.py +80 -0
- tressed/loader/specializer.py +52 -0
- tressed/loader/specializers.py +219 -0
- tressed/loader/types.py +26 -0
- tressed/predicates.py +128 -0
- tressed/py.typed +0 -0
- tressed-0.0.1.dist-info/METADATA +88 -0
- tressed-0.0.1.dist-info/RECORD +15 -0
- tressed-0.0.1.dist-info/WHEEL +5 -0
- tressed-0.0.1.dist-info/top_level.txt +1 -0
tressed/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
tressed/__version__.py
ADDED
|
@@ -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}'")
|
tressed/exceptions.py
ADDED
|
@@ -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}'")
|
tressed/loader/loader.py
ADDED
|
@@ -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()
|
tressed/loader/types.py
ADDED
|
@@ -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
|
+
]
|
tressed/predicates.py
ADDED
|
@@ -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
|
tressed/py.typed
ADDED
|
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,15 @@
|
|
|
1
|
+
tressed/__init__.py,sha256=da1PTClDMl-IBkrSvq6JC1lnS-K_BASzCvxVhNxN5Ls,13
|
|
2
|
+
tressed/__version__.py,sha256=dX3yXfRP2ZDFjTPZdcDtG6g9WiTrOTTdjSs-s_yTDCI,322
|
|
3
|
+
tressed/exceptions.py,sha256=WfPu_fPLH7TUC6njmNpQ3hYXRblMdKAM1X4maFDJPgI,232
|
|
4
|
+
tressed/predicates.py,sha256=ErdPm94LnguB2MO0_6wqlCRLohIbaubPlyIaU_jMZNc,3452
|
|
5
|
+
tressed/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
tressed/loader/__init__.py,sha256=qkO2MxvVUN_9Mi2YUNFssWLV-Qn5y7X0QgHJdl4ss70,331
|
|
7
|
+
tressed/loader/loader.py,sha256=041veJW-Uwd9Se5DDKXaJu4gDDH3J2yAkF2KYLO_0yA,5085
|
|
8
|
+
tressed/loader/loaders.py,sha256=xsq5ONJctuij0gXf2qFoMT-P2yLwLIDEo9F-k1egcBM,2377
|
|
9
|
+
tressed/loader/specializer.py,sha256=u7r7qv1kOS61sguYD6q6TOwx7tzM1GAUxxdoCXzfEzE,1755
|
|
10
|
+
tressed/loader/specializers.py,sha256=sO54CaVFcCTymZ3py8_eH1MP4dxMPv-44G1wb-6a5Jk,6086
|
|
11
|
+
tressed/loader/types.py,sha256=xEuPWB9V0M4LkhF7bLx-upQqlzHUoGl1zkgnYT1IuAg,683
|
|
12
|
+
tressed-0.0.1.dist-info/METADATA,sha256=WglxjqLLfIxncmenKdMPChlrb3_ilUcP5OCqiOah8Os,2411
|
|
13
|
+
tressed-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
tressed-0.0.1.dist-info/top_level.txt,sha256=R3Xw_AtSlP9ol3NXmxHi7TMTSsY60TP6L_70Z--oG9g,8
|
|
15
|
+
tressed-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tressed
|