thds.attrs-utils 1.6.20251103154246__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.
- thds/attrs_utils/__init__.py +4 -0
- thds/attrs_utils/cattrs/__init__.py +29 -0
- thds/attrs_utils/cattrs/converter.py +182 -0
- thds/attrs_utils/cattrs/errors.py +46 -0
- thds/attrs_utils/defaults.py +31 -0
- thds/attrs_utils/docs.py +75 -0
- thds/attrs_utils/empty.py +146 -0
- thds/attrs_utils/isinstance/__init__.py +4 -0
- thds/attrs_utils/isinstance/check.py +107 -0
- thds/attrs_utils/isinstance/registry.py +13 -0
- thds/attrs_utils/isinstance/util.py +106 -0
- thds/attrs_utils/jsonschema/__init__.py +11 -0
- thds/attrs_utils/jsonschema/constructors.py +90 -0
- thds/attrs_utils/jsonschema/jsonschema.py +326 -0
- thds/attrs_utils/jsonschema/str_formats.py +109 -0
- thds/attrs_utils/jsonschema/util.py +94 -0
- thds/attrs_utils/py.typed +0 -0
- thds/attrs_utils/random/__init__.py +3 -0
- thds/attrs_utils/random/attrs.py +57 -0
- thds/attrs_utils/random/builtin.py +103 -0
- thds/attrs_utils/random/collection.py +41 -0
- thds/attrs_utils/random/gen.py +134 -0
- thds/attrs_utils/random/optional.py +13 -0
- thds/attrs_utils/random/registry.py +30 -0
- thds/attrs_utils/random/tuple.py +24 -0
- thds/attrs_utils/random/union.py +33 -0
- thds/attrs_utils/random/util.py +55 -0
- thds/attrs_utils/recursion.py +48 -0
- thds/attrs_utils/registry.py +40 -0
- thds/attrs_utils/type_cache.py +110 -0
- thds/attrs_utils/type_recursion.py +168 -0
- thds/attrs_utils/type_utils.py +180 -0
- thds_attrs_utils-1.6.20251103154246.dist-info/METADATA +230 -0
- thds_attrs_utils-1.6.20251103154246.dist-info/RECORD +36 -0
- thds_attrs_utils-1.6.20251103154246.dist-info/WHEEL +5 -0
- thds_attrs_utils-1.6.20251103154246.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from collections import ChainMap, deque
|
|
2
|
+
from types import MemberDescriptorType, ModuleType
|
|
3
|
+
from typing import Any, Collection, Dict, Generic, Iterator, Optional, Set, Tuple, Type, Union
|
|
4
|
+
|
|
5
|
+
from .type_utils import T, typename
|
|
6
|
+
|
|
7
|
+
CLASS_DUNDER_NAMES: Set[str] = (
|
|
8
|
+
set(vars(object))
|
|
9
|
+
.union(dir(ModuleType("")))
|
|
10
|
+
.union(
|
|
11
|
+
{
|
|
12
|
+
"__name__",
|
|
13
|
+
"__file__",
|
|
14
|
+
"__module__",
|
|
15
|
+
"__dict__",
|
|
16
|
+
"__weakref__",
|
|
17
|
+
"__builtins__",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
#######################################################
|
|
23
|
+
# Store for naming types from many modules succinctly #
|
|
24
|
+
# and keeping track of computations on them #
|
|
25
|
+
#######################################################
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TypeCache(Generic[T]):
|
|
29
|
+
"""Key-value store for type objects whose main purpose is to provide canonical names for types,
|
|
30
|
+
given a set of modules they are defined in. Passing modules via keyword allows you to override the
|
|
31
|
+
name of a module in the final dotted name of a type, in case there is some display concern where that
|
|
32
|
+
would be useful, or when generating an external-facing interface where stability is required even
|
|
33
|
+
while the internal module structure may be changing. Also allows storage of metadata about types;
|
|
34
|
+
this is what is parameterized by the `T` type variable.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
|
|
38
|
+
import builtins, datetime
|
|
39
|
+
from thds.attrs_utils import type_cache
|
|
40
|
+
|
|
41
|
+
types = TypeCache(internal=[type_cache], python=[builtins, datetime])
|
|
42
|
+
|
|
43
|
+
print(types.name_of(int))
|
|
44
|
+
print(types.name_of(datetime.date))
|
|
45
|
+
print(types.name_of(type_cache.TypeCache))
|
|
46
|
+
|
|
47
|
+
# python.int
|
|
48
|
+
# python.date
|
|
49
|
+
# internal.TypeCache
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, **modules: Union[ModuleType, Collection[ModuleType]]):
|
|
53
|
+
name_lookups = []
|
|
54
|
+
for module_name, m in modules.items():
|
|
55
|
+
ms: Collection[ModuleType]
|
|
56
|
+
ms = [m] if isinstance(m, ModuleType) else m
|
|
57
|
+
for module in ms: # type: ignore
|
|
58
|
+
names = {
|
|
59
|
+
id(obj): name
|
|
60
|
+
for name, obj in object_names_recursive(module, cache=set(), name=module_name)
|
|
61
|
+
}
|
|
62
|
+
name_lookups.append(names)
|
|
63
|
+
|
|
64
|
+
self.type_names = ChainMap(*name_lookups)
|
|
65
|
+
self.schemas: Dict[int, T] = {}
|
|
66
|
+
|
|
67
|
+
def name_of(self, type_: Type):
|
|
68
|
+
if id(type_) in self.type_names:
|
|
69
|
+
return self.type_names[id(type_)]
|
|
70
|
+
return typename(type_)
|
|
71
|
+
|
|
72
|
+
def base_name_of(self, type_: Type):
|
|
73
|
+
return self.name_of(type_).split(".")[-1]
|
|
74
|
+
|
|
75
|
+
def __setitem__(self, key: Type, value: T):
|
|
76
|
+
# some type objects which are distinct (different `id`) actually compare equal, so we have to
|
|
77
|
+
# store and look up distinct types by `id` to avoid collisions and therefore ambiguous naming;
|
|
78
|
+
# otherwise, the `name_of` a type could change during the course of program execution as the
|
|
79
|
+
# contents of the cache are populated
|
|
80
|
+
self.schemas[id(key)] = value
|
|
81
|
+
|
|
82
|
+
def __getitem__(self, key: Type) -> T:
|
|
83
|
+
return self.schemas[id(key)]
|
|
84
|
+
|
|
85
|
+
def __contains__(self, key: Type) -> bool:
|
|
86
|
+
return id(key) in self.schemas
|
|
87
|
+
|
|
88
|
+
def pop(self, key: Type) -> T:
|
|
89
|
+
return self.schemas.pop(id(key))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def object_names_recursive(
|
|
93
|
+
module: Union[ModuleType, Type],
|
|
94
|
+
cache: Set[int],
|
|
95
|
+
name: Optional[str] = None,
|
|
96
|
+
) -> Iterator[Tuple[str, Any]]:
|
|
97
|
+
"""Names of objects, recursing into namespaces of classes, breadth-first"""
|
|
98
|
+
q = deque([(name or module.__name__, module)])
|
|
99
|
+
while q:
|
|
100
|
+
prefix, module = q.popleft()
|
|
101
|
+
for name, obj in vars(module).items():
|
|
102
|
+
if (
|
|
103
|
+
name not in CLASS_DUNDER_NAMES
|
|
104
|
+
and not isinstance(obj, MemberDescriptorType)
|
|
105
|
+
and id(obj) not in cache
|
|
106
|
+
):
|
|
107
|
+
yield f"{prefix}.{name}", obj
|
|
108
|
+
cache.add(id(obj))
|
|
109
|
+
if isinstance(obj, type): # recurse
|
|
110
|
+
q.append((f"{prefix}.{name}", obj))
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from functools import partial
|
|
3
|
+
from typing import List, Optional, Tuple, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
import attr
|
|
6
|
+
from typing_inspect import is_tuple_type, is_typevar, is_union_type
|
|
7
|
+
|
|
8
|
+
from . import type_utils
|
|
9
|
+
from .recursion import F, Params, Predicate, RecF, StructuredRecursion, U, _value_error
|
|
10
|
+
from .registry import Registry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_newtype(
|
|
14
|
+
recurse: F[Type, Params, U], type_: Type, *args: Params.args, **kwargs: Params.kwargs
|
|
15
|
+
) -> U:
|
|
16
|
+
"""Default implementation for `typing.NewType` that recurses by simply applying the recursion to the
|
|
17
|
+
ultimate concrete type underlying the newtype"""
|
|
18
|
+
return recurse(type_utils.newtype_base(type_), *args, **kwargs)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_annotated(
|
|
22
|
+
recurse: F[Type, Params, U], type_: Type, *args: Params.args, **kwargs: Params.kwargs
|
|
23
|
+
) -> U:
|
|
24
|
+
"""Default implementation for `typing.Annotated` that recurses by simply applying the recursion to
|
|
25
|
+
the type that was wrapped with the annotation"""
|
|
26
|
+
return recurse(type_utils.unwrap_annotated(type_), *args, **kwargs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _try_bases(
|
|
30
|
+
msg: str,
|
|
31
|
+
exc_type: Type[Exception],
|
|
32
|
+
# placeholder for the recursive function in case you wish to specify this explictly as one of the
|
|
33
|
+
# recursions; type doesn't matter
|
|
34
|
+
f: F[Type, Params, U],
|
|
35
|
+
type_: Type,
|
|
36
|
+
*args: Params.args,
|
|
37
|
+
**kwargs: Params.kwargs,
|
|
38
|
+
) -> U:
|
|
39
|
+
try:
|
|
40
|
+
mro = type_.mro()
|
|
41
|
+
except (AttributeError, TypeError):
|
|
42
|
+
return _value_error(msg, exc_type, f, type_, *args, **kwargs)
|
|
43
|
+
for base in mro[1:]:
|
|
44
|
+
try:
|
|
45
|
+
return f(base, *args, **kwargs)
|
|
46
|
+
except TypeError:
|
|
47
|
+
pass
|
|
48
|
+
return _value_error(msg, exc_type, f, type_, *args, **kwargs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def try_bases(msg: str, exc_type: Type[Exception]) -> RecF[Type, Params, U]:
|
|
52
|
+
"""Helper to be passed as the `otherwise` of a `TypeRecursion` for handling cases of types which
|
|
53
|
+
inherit from some known type that is not otherwise explicitly registered"""
|
|
54
|
+
return partial(_try_bases, msg, exc_type) # type: ignore[return-value]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TypeRecursion(StructuredRecursion[Type, Params, U]):
|
|
58
|
+
"""Convenience class for defining functions which operate on runtime representations of types
|
|
59
|
+
recursively. Handles the appropriate ordering of predicates such that more specific predicates
|
|
60
|
+
precede more general ones. Also handles caching of results using the `registry` when `cached=True`,
|
|
61
|
+
to allow for caching of results, since generally types are hashable never go out of program scope,
|
|
62
|
+
and are not terribly numerous. With or without caching, the `registry` allows overriding with custom
|
|
63
|
+
behavior for any specific type by using the `.register` method. Behavior registered this way will
|
|
64
|
+
take precedence regardless of which predicates match the registered type."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
registry: Registry[Type, U],
|
|
69
|
+
cached: bool = True,
|
|
70
|
+
*,
|
|
71
|
+
first: Optional[Tuple[Predicate[Type], RecF[Type, Params, U]]] = None,
|
|
72
|
+
attrs: Optional[RecF[Type, Params, U]] = None,
|
|
73
|
+
namedtuple: Optional[RecF[Type, Params, U]] = None,
|
|
74
|
+
optional: Optional[RecF[Type, Params, U]] = None,
|
|
75
|
+
union: Optional[RecF[Type, Params, U]] = None,
|
|
76
|
+
literal: Optional[RecF[Type, Params, U]] = None,
|
|
77
|
+
enum: Optional[RecF[Type, Params, U]] = None,
|
|
78
|
+
set: Optional[RecF[Type, Params, U]] = None,
|
|
79
|
+
mapping: Optional[RecF[Type, Params, U]] = None,
|
|
80
|
+
collection: Optional[RecF[Type, Params, U]] = None,
|
|
81
|
+
tuple: Optional[RecF[Type, Params, U]] = None,
|
|
82
|
+
variadic_tuple: Optional[RecF[Type, Params, U]] = None,
|
|
83
|
+
newtype: Optional[RecF[Type, Params, U]] = default_newtype, # type: ignore[assignment]
|
|
84
|
+
annotated: Optional[RecF[Type, Params, U]] = default_annotated, # type: ignore[assignment]
|
|
85
|
+
typevar: Optional[RecF[Type, Params, U]] = None,
|
|
86
|
+
otherwise: RecF[Type, Params, U],
|
|
87
|
+
):
|
|
88
|
+
prioritized_funcs: List[Tuple[Predicate[Type], Optional[RecF[Type, Params, U]]]] = (
|
|
89
|
+
[] if first is None else [first]
|
|
90
|
+
)
|
|
91
|
+
prioritized_funcs.extend(
|
|
92
|
+
[
|
|
93
|
+
(is_typevar, typevar),
|
|
94
|
+
(type_utils.is_annotated_type, annotated),
|
|
95
|
+
(type_utils.is_new_type, newtype),
|
|
96
|
+
(type_utils.is_literal_type, literal),
|
|
97
|
+
(type_utils.is_enum_type, enum),
|
|
98
|
+
(attr.has, attrs),
|
|
99
|
+
(type_utils.is_optional_type, optional),
|
|
100
|
+
(is_union_type, union),
|
|
101
|
+
(type_utils.is_set_type, set),
|
|
102
|
+
(type_utils.is_mapping_type, mapping),
|
|
103
|
+
(type_utils.is_namedtuple_type, namedtuple),
|
|
104
|
+
(type_utils.is_variadic_tuple_type, variadic_tuple),
|
|
105
|
+
(is_tuple_type, tuple),
|
|
106
|
+
(type_utils.is_collection_type, collection),
|
|
107
|
+
(type_utils.is_annotated_type, annotated),
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
self.registry = registry
|
|
111
|
+
self.cached = cached
|
|
112
|
+
super().__init__(
|
|
113
|
+
[(predicate, f) for predicate, f in prioritized_funcs if f is not None],
|
|
114
|
+
otherwise,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def register(self, type_: Type, value: Optional[U] = None):
|
|
118
|
+
"""Convenience decorator factory"""
|
|
119
|
+
if value is None:
|
|
120
|
+
return self.registry.register(type_)
|
|
121
|
+
else:
|
|
122
|
+
return self.registry.register(type_, value)
|
|
123
|
+
|
|
124
|
+
def __call__(self, obj: Type, *args: Params.args, **kwargs: Params.kwargs) -> U:
|
|
125
|
+
if self.registry is None:
|
|
126
|
+
return super().__call__(obj, *args, **kwargs)
|
|
127
|
+
try:
|
|
128
|
+
result = self.registry[obj]
|
|
129
|
+
except KeyError:
|
|
130
|
+
result = super().__call__(obj, *args, **kwargs)
|
|
131
|
+
if self.cached:
|
|
132
|
+
self.registry[obj] = result
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
T = TypeVar("T")
|
|
137
|
+
Constructor = typing.Callable[..., T]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ConstructorFactory(TypeRecursion[Params, Constructor]):
|
|
141
|
+
"""Specialization of `TypeRecursion` for the common case of functions that take a type and return a function that
|
|
142
|
+
returns instances of that type. Common examples include random or default instance generators.
|
|
143
|
+
|
|
144
|
+
Consider the following example:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
random_gen: TypeRecursion[[], Factory]]
|
|
148
|
+
|
|
149
|
+
random_int = random_gen(int) # returns a function that generates random integers
|
|
150
|
+
random_str = random_gen(str) # returns a function that generates random strings
|
|
151
|
+
|
|
152
|
+
def add_one(x: int) -> int:
|
|
153
|
+
return x + 1
|
|
154
|
+
|
|
155
|
+
add_one(random_int()) # totally fine
|
|
156
|
+
add_one(random_str()) # type error, but `mypy` doesn't catch it
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
In this case, `add_one(random_str())` is a type error, but `mypy` doesn't catch it because `random_gen`'s signature
|
|
160
|
+
does not constrain the returned constructor as returning the same type as the input type. If, on the other hand,
|
|
161
|
+
`random_gen` had been typed as a `ConstructorFactory`, the type checker would be able to catch this error.
|
|
162
|
+
It is up to you as the implementer to ensure that your implementation of any `ConstructorFactory` actually respects
|
|
163
|
+
this constraint, since the implementation is too dynamic for most type checkers to verify this automatically, but if
|
|
164
|
+
you do so, then the type checker _can_ detect common errors downstream of any given constructor creation.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __call__(self, type_: Type[T], *args: Params.args, **kwargs: Params.kwargs) -> Constructor[T]: # type: ignore[override]
|
|
168
|
+
return super().__call__(type_)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import enum
|
|
3
|
+
import inspect
|
|
4
|
+
import typing
|
|
5
|
+
from typing import Callable, List, Mapping, Optional, Tuple, Type, TypeVar, Union
|
|
6
|
+
|
|
7
|
+
import attr
|
|
8
|
+
from typing_inspect import (
|
|
9
|
+
get_args,
|
|
10
|
+
get_origin,
|
|
11
|
+
get_parameters,
|
|
12
|
+
is_literal_type,
|
|
13
|
+
is_new_type,
|
|
14
|
+
is_optional_type,
|
|
15
|
+
is_tuple_type,
|
|
16
|
+
is_typevar,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
COLLECTION_TYPES = set(
|
|
22
|
+
map(
|
|
23
|
+
get_origin,
|
|
24
|
+
[
|
|
25
|
+
typing.List,
|
|
26
|
+
typing.Tuple,
|
|
27
|
+
typing.Set,
|
|
28
|
+
typing.MutableSet,
|
|
29
|
+
typing.FrozenSet,
|
|
30
|
+
typing.AbstractSet,
|
|
31
|
+
typing.Sequence,
|
|
32
|
+
typing.Collection,
|
|
33
|
+
],
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
UNIQUE_COLLECTION_TYPES = set(
|
|
37
|
+
map(get_origin, [typing.Set, typing.MutableSet, typing.FrozenSet, typing.AbstractSet])
|
|
38
|
+
)
|
|
39
|
+
MAPPING_TYPES = set(
|
|
40
|
+
map(
|
|
41
|
+
get_origin,
|
|
42
|
+
[
|
|
43
|
+
typing.Dict,
|
|
44
|
+
typing.Mapping,
|
|
45
|
+
typing.MutableMapping,
|
|
46
|
+
typing.DefaultDict,
|
|
47
|
+
typing.OrderedDict,
|
|
48
|
+
typing.Counter,
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
TUPLE = get_origin(Tuple)
|
|
53
|
+
ORIGIN_TO_CONSTRUCTOR = {
|
|
54
|
+
get_origin(t): c
|
|
55
|
+
for t, c in [
|
|
56
|
+
(typing.List, list),
|
|
57
|
+
(typing.Tuple, tuple),
|
|
58
|
+
(typing.Set, set),
|
|
59
|
+
(typing.MutableSet, set),
|
|
60
|
+
(typing.AbstractSet, set),
|
|
61
|
+
(typing.FrozenSet, frozenset),
|
|
62
|
+
(typing.Sequence, list),
|
|
63
|
+
(typing.Collection, list),
|
|
64
|
+
(typing.Dict, dict),
|
|
65
|
+
(typing.Mapping, dict),
|
|
66
|
+
(typing.MutableMapping, dict),
|
|
67
|
+
(typing.OrderedDict, collections.OrderedDict),
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def typename(type_: Type) -> str:
|
|
73
|
+
if attr.has(type_) or is_new_type(type_) or is_typevar(type_):
|
|
74
|
+
return type_.__name__
|
|
75
|
+
else:
|
|
76
|
+
raise TypeError(f"can't generate meaningful name for type {type_}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def newtype_base(type_: Type) -> Type:
|
|
80
|
+
return newtype_base(type_.__supertype__) if is_new_type(type_) else type_
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def literal_base(type_: Type) -> Type:
|
|
84
|
+
assert is_literal_type(type_)
|
|
85
|
+
types = tuple(map(type, get_args(type_)))
|
|
86
|
+
return Union[types] # type: ignore
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def enum_base(type_: Type) -> Type:
|
|
90
|
+
assert is_enum_type(type_)
|
|
91
|
+
if issubclass(type_, int): # IntEnum, IntFlag
|
|
92
|
+
return int
|
|
93
|
+
types = tuple(type(e.value) for e in type_)
|
|
94
|
+
return Union[types] # type: ignore
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def unwrap_optional(type_: Type) -> Type:
|
|
98
|
+
if is_optional_type(type_):
|
|
99
|
+
args = get_args(type_)
|
|
100
|
+
return Union[tuple(a for a in args if a is not type(None))] # type: ignore # noqa:E721
|
|
101
|
+
else:
|
|
102
|
+
return type_
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_annotated_type(type_: Type) -> bool:
|
|
106
|
+
return (
|
|
107
|
+
hasattr(type_, "__origin__")
|
|
108
|
+
and hasattr(type_, "__args__")
|
|
109
|
+
and isinstance(getattr(type_, "__metadata__", None), tuple)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def unwrap_annotated(type_: Type) -> Type:
|
|
114
|
+
if is_annotated_type(type_):
|
|
115
|
+
return type_.__origin__
|
|
116
|
+
return type_
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_enum_type(type_: Type) -> bool:
|
|
120
|
+
return isinstance(type_, type) and issubclass(type_, enum.Enum)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_collection_type(type_: Type) -> bool:
|
|
124
|
+
origin = get_origin(type_)
|
|
125
|
+
return (type_ in COLLECTION_TYPES) if origin is None else (origin in COLLECTION_TYPES)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_mapping_type(type_: Type) -> bool:
|
|
129
|
+
origin = get_origin(type_)
|
|
130
|
+
return (type_ in MAPPING_TYPES) if origin is None else (origin in MAPPING_TYPES)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_set_type(type_: Type) -> bool:
|
|
134
|
+
origin = get_origin(type_)
|
|
135
|
+
return (type_ in UNIQUE_COLLECTION_TYPES) if origin is None else (origin in UNIQUE_COLLECTION_TYPES)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_namedtuple_type(type_: Type) -> bool:
|
|
139
|
+
return getattr(type_, "__bases__", None) == (tuple,) and hasattr(type_, "_fields")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_variadic_tuple_type(type_: Type) -> bool:
|
|
143
|
+
if is_tuple_type(type_):
|
|
144
|
+
args = get_args(type_)
|
|
145
|
+
return len(args) == 2 and args[-1] is Ellipsis
|
|
146
|
+
else:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def is_builtin_type(type_: Type) -> bool:
|
|
151
|
+
return getattr(type_, "__module__", None) == "builtins" and not get_args(type_)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def concrete_constructor(type_: Type[T]) -> Callable[..., T]:
|
|
155
|
+
if is_namedtuple_type(type_):
|
|
156
|
+
return type_
|
|
157
|
+
origin = get_origin(type_)
|
|
158
|
+
return ORIGIN_TO_CONSTRUCTOR[type_] if origin is None else ORIGIN_TO_CONSTRUCTOR[origin]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parameterize(
|
|
162
|
+
type_: Union[Type, TypeVar], params: Mapping[TypeVar, Union[TypeVar, Type]]
|
|
163
|
+
) -> Union[Type, TypeVar]:
|
|
164
|
+
if isinstance(type_, TypeVar):
|
|
165
|
+
return params.get(type_, type_)
|
|
166
|
+
else:
|
|
167
|
+
tparams = get_parameters(type_)
|
|
168
|
+
new_args = tuple(params.get(t, t) for t in tparams)
|
|
169
|
+
return type_[new_args]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def bases(type_: Type, predicate: Optional[Callable[[Type], bool]] = None) -> List[Type]:
|
|
173
|
+
if not inspect.isclass(type_):
|
|
174
|
+
raise TypeError(
|
|
175
|
+
f"{bases.__module__}.{bases.__name__} can be called only on concrete classes; got {type_}"
|
|
176
|
+
)
|
|
177
|
+
elif predicate is None:
|
|
178
|
+
return list(inspect.getmro(type_))
|
|
179
|
+
else:
|
|
180
|
+
return list(filter(predicate, inspect.getmro(type_)))
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thds.attrs-utils
|
|
3
|
+
Version: 1.6.20251103154246
|
|
4
|
+
Summary: Utilities for attrs record classes.
|
|
5
|
+
Author-email: Trillianth Health <info@trillianthealth.com>
|
|
6
|
+
Project-URL: Repository, https://github.com/TrilliantHealth/ds-monorepo
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: attrs>=22.2.0
|
|
10
|
+
Requires-Dist: returns
|
|
11
|
+
Requires-Dist: thds-core
|
|
12
|
+
Requires-Dist: typing-inspect
|
|
13
|
+
Provides-Extra: cattrs
|
|
14
|
+
Requires-Dist: cattrs>=22.2.0; extra == "cattrs"
|
|
15
|
+
Provides-Extra: docstrings
|
|
16
|
+
Requires-Dist: docstring-parser; extra == "docstrings"
|
|
17
|
+
Provides-Extra: jsonschema
|
|
18
|
+
Requires-Dist: fastjsonschema; extra == "jsonschema"
|
|
19
|
+
|
|
20
|
+
# `thds.attrs-utils` Library
|
|
21
|
+
|
|
22
|
+
This library contains utilities for working with basic data types and type annotations in a generic way -
|
|
23
|
+
transforming, checking, generating, or anything else you'd want to do with types.
|
|
24
|
+
|
|
25
|
+
## Supported types:
|
|
26
|
+
|
|
27
|
+
- Builtin types, e.g. `int`, `str`, `float`, `bool`, `bytes`
|
|
28
|
+
- `datetime.date`, `datetime.datetime`
|
|
29
|
+
- Most standard library collection types, e.g. `List[T]`, `Sequence[T]`, `Dict[K, V]`, `Mapping[K, V]`,
|
|
30
|
+
`Set[T]`
|
|
31
|
+
- Heterogeneous tuple types, e.g. `typing.Tuple[A, B, C]`
|
|
32
|
+
- Variadic tuple types, e.g. `Tuple[T, ...]`
|
|
33
|
+
- `typing.NamedTuple` record types
|
|
34
|
+
- `attrs`-defined record types
|
|
35
|
+
- Union types using `typing.Union`
|
|
36
|
+
- `typing.Literal`
|
|
37
|
+
- `typing.Annotated`
|
|
38
|
+
- `typing.NewType`
|
|
39
|
+
|
|
40
|
+
## General Recursion Framework
|
|
41
|
+
|
|
42
|
+
The `thds.attrs_utils.type_recursion` module defines a generic interface for performing operations on
|
|
43
|
+
arbitrarily nested types. If you have some operation you'd like to do, e.g. transform a python data model
|
|
44
|
+
into some other schema language, or define a generic validation check, all you need to do is define it on
|
|
45
|
+
a particular set of cases.
|
|
46
|
+
|
|
47
|
+
For example, here's a simple implementation that counts the number of types referenced inside of a nested
|
|
48
|
+
type definition:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from typing import List, Mapping, Tuple, get_args
|
|
52
|
+
from thds.attrs_utils.type_recursion import TypeRecursion, Registry
|
|
53
|
+
|
|
54
|
+
def n_types_generic(recurse, type_):
|
|
55
|
+
args = get_args(type_)
|
|
56
|
+
return 1 + sum(map(recurse, args))
|
|
57
|
+
|
|
58
|
+
n_types = TypeRecursion(
|
|
59
|
+
Registry(),
|
|
60
|
+
tuple=n_types_generic, # these aren't strictly required because the implementation is the same for all of them
|
|
61
|
+
collection=n_types_generic, # but I include them
|
|
62
|
+
mapping=n_types_generic,
|
|
63
|
+
otherwise=n_types_generic,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
print(n_types(Mapping[Tuple[int, str], List[bytes]]))
|
|
67
|
+
# 1 2 3 4 5 6
|
|
68
|
+
# 6
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This example is very simple to illustrate the point. However, much more complex use cases are enabled by
|
|
72
|
+
the framework. Most useful are type recursions which accept types and return _callables_ that apply to or
|
|
73
|
+
return values inhabiting those types. Examples included in this library are
|
|
74
|
+
|
|
75
|
+
- an instance checker takes an arbitrarily nested type and returns a callable which recursively checks
|
|
76
|
+
that all fields inside a nested value are of the expected type
|
|
77
|
+
- a jsonschema generator which takes a type and returns a jsonschema, which can then be used to validate
|
|
78
|
+
deserialized values that may be structured into instances of that type
|
|
79
|
+
- a random generator which takes a type and returns random instances of that type
|
|
80
|
+
|
|
81
|
+
Note that the cases which return callables are _static_ with respect to the given type. This allows you
|
|
82
|
+
to freeze the callable as specialized to a specific type, so that the type itself only has to be
|
|
83
|
+
inspected only once - the callable itself only needs to inspect values.
|
|
84
|
+
|
|
85
|
+
## Use Cases in this Library
|
|
86
|
+
|
|
87
|
+
This library includes a few useful implementations of the above pattern.
|
|
88
|
+
|
|
89
|
+
### Random Data Generation
|
|
90
|
+
|
|
91
|
+
You can create a callable to generate instances of a given type as follows:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import itertools
|
|
95
|
+
from typing import Dict, Literal, NewType, Optional, Tuple
|
|
96
|
+
import attr
|
|
97
|
+
from thds.attrs_utils.random.builtin import random_bool_gen, random_int_gen, random_str_gen
|
|
98
|
+
from thds.attrs_utils.random.tuple import random_tuple_gen
|
|
99
|
+
from thds.attrs_utils.random.attrs import register_random_gen_by_field
|
|
100
|
+
from thds.attrs_utils.random import random_gen
|
|
101
|
+
|
|
102
|
+
@register_random_gen_by_field(
|
|
103
|
+
a=random_str_gen(random_int_gen(1, 3), "ABCD"),
|
|
104
|
+
b=random_tuple_gen(random_int_gen(0, 3), random_bool_gen(0.99))
|
|
105
|
+
)
|
|
106
|
+
@attr.define
|
|
107
|
+
class Record1:
|
|
108
|
+
a: Optional[str]
|
|
109
|
+
b: Tuple[int, bool]
|
|
110
|
+
|
|
111
|
+
ID = NewType("ID", int)
|
|
112
|
+
Key = Literal["foo", "bar", "baz"]
|
|
113
|
+
|
|
114
|
+
@attr.define
|
|
115
|
+
class Record2:
|
|
116
|
+
id: ID
|
|
117
|
+
records: Dict[Key, Record1]
|
|
118
|
+
|
|
119
|
+
ids = itertools.count(1)
|
|
120
|
+
random_gen.register(ID, lambda: next(ids))
|
|
121
|
+
|
|
122
|
+
random_record = random_gen(Record2)
|
|
123
|
+
|
|
124
|
+
print(random_record())
|
|
125
|
+
print(random_record())
|
|
126
|
+
# Record2(id=1, records={'bar': Record1(a='B', b=(1, True)), 'baz': Record1(a='C', b=(3, True)), 'foo': Record1(a='ACB', b=(1, True))})
|
|
127
|
+
# Record2(id=2, records={'foo': Record1(a='A', b=(3, True)), 'bar': Record1(a='ADB', b=(1, True)), 'baz': Record1(a='CAD', b=(0, True))})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This can be useful for certain kinds of tests, e.g. round-trip tests, run-time profiling, and
|
|
131
|
+
property-based tests. It saves you maintenance because you don't need a sample of "real" data that is
|
|
132
|
+
completely up to date with your data model changes, and it saves you time because it's faster to generate
|
|
133
|
+
random instances in memory than to fetch a file and deserialize instances from it.
|
|
134
|
+
|
|
135
|
+
### Validation
|
|
136
|
+
|
|
137
|
+
There are two kinds of validation provided in this library: jsonschema validation and basic instance
|
|
138
|
+
checking.
|
|
139
|
+
|
|
140
|
+
#### Jsonschema
|
|
141
|
+
|
|
142
|
+
Jsonschema validation applies to an "unstructured" precursor of your data that would come, e.g. from
|
|
143
|
+
parsing json or deserializing data in some other way. This expects a value composed of builtin python
|
|
144
|
+
types - dicts, lists, strings, ints, floats, bools, and null values, arbitrarily nested.
|
|
145
|
+
|
|
146
|
+
To generate a jsonschema for your type (usually a nested record type of some kind), you need only run the
|
|
147
|
+
following:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from thds.attrs_utils.jsonschema import to_jsonschema, jsonschema_validator
|
|
151
|
+
|
|
152
|
+
from my_library import my_module
|
|
153
|
+
|
|
154
|
+
schema = to_jsonschema(my_module.MyRecordType, modules=[my_module])
|
|
155
|
+
|
|
156
|
+
check = jsonschema_validator(schema)
|
|
157
|
+
|
|
158
|
+
check({}) # fails for absence of fields defined in my_module.MyRecordType
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### Simple instance checks
|
|
162
|
+
|
|
163
|
+
Instance checking asserts that the run time types of all references inside some object are as expected.
|
|
164
|
+
It is semantically similar to the builtin `isinstance`, but checks all references inside an object
|
|
165
|
+
recursively.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from typing import Literal, Mapping
|
|
169
|
+
|
|
170
|
+
from thds.attrs_utils.isinstance import isinstance as deep_isinstance
|
|
171
|
+
|
|
172
|
+
Num = Literal["one", "two", "three"]
|
|
173
|
+
|
|
174
|
+
value = {"one": 2, "three": 4}
|
|
175
|
+
|
|
176
|
+
# can't use `isinstance` with parameterized types
|
|
177
|
+
print(isinstance(value, Mapping))
|
|
178
|
+
# True
|
|
179
|
+
print(deep_isinstance(value, Mapping[Num, int]))
|
|
180
|
+
# True
|
|
181
|
+
print(deep_isinstance(value, Mapping[str, int]))
|
|
182
|
+
# True
|
|
183
|
+
print(deep_isinstance(value, Mapping[Num, str]))
|
|
184
|
+
# False
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
This can be useful for validating data from an unknown source, but is generally less useful that
|
|
188
|
+
jsonschema validation, because it applies to data that has already been "structured", (assuming that the
|
|
189
|
+
input was even in the correct shape for such an operation), and most of the errors it would catch could
|
|
190
|
+
also be caught statically and more efficiently via static type checking. We provide it mainly as a
|
|
191
|
+
reference implementation for using the `TypeRecursion` framework in a relatively simple, but mostly
|
|
192
|
+
complete way. We also use it in a property-based test of random data generation; for any type `T`,
|
|
193
|
+
`isinstance(random_gen(T)(), T)` should hold.
|
|
194
|
+
|
|
195
|
+
## Serialization/Deserialization
|
|
196
|
+
|
|
197
|
+
The `thds.attrs_utils.cattrs` submodule defines useful defaults for serialization/deserialization of
|
|
198
|
+
values of various types, and utils to customize behavior for your own custom types, should you need to.
|
|
199
|
+
The goal is that the defaults do what you want in 99% of cases.
|
|
200
|
+
|
|
201
|
+
To use the converters:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from thds.attrs_utils.cattrs import DEFAULT_JSON_CONVERTER
|
|
205
|
+
|
|
206
|
+
from my_library import my_module
|
|
207
|
+
|
|
208
|
+
ready_for_json = DEFAULT_JSON_CONVERTER.unstructure(my_module.MyRecordType())
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
or if you require some custom behavior, you may define your own hooks and use helper functions to
|
|
212
|
+
construct your own converter. Here's an example where we register custom hooks for the UUID type, which
|
|
213
|
+
you would need if that type was present in your data model:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from typing import Type
|
|
217
|
+
from uuid import UUID
|
|
218
|
+
|
|
219
|
+
from thds.attrs_utils.cattrs import default_converter, setup_converter, DEFAULT_STRUCTURE_HOOKS, DEFAULT_UNSTRUCTURE_HOOKS_JSON
|
|
220
|
+
|
|
221
|
+
def structure_uuid(s: str, type_: Type[UUID]) -> UUID:
|
|
222
|
+
return type_(s)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
CONVERTER = setup_converter(
|
|
226
|
+
default_converter(),
|
|
227
|
+
struct_hooks=[*DEFAULT_STRUCTURE_HOOKS, (UUID, structure_uuid)],
|
|
228
|
+
unstruct_hooks=[*DEFAULT_UNSTRUCTURE_HOOKS_JSON, (UUID, str)],
|
|
229
|
+
)
|
|
230
|
+
```
|