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.
Files changed (36) hide show
  1. thds/attrs_utils/__init__.py +4 -0
  2. thds/attrs_utils/cattrs/__init__.py +29 -0
  3. thds/attrs_utils/cattrs/converter.py +182 -0
  4. thds/attrs_utils/cattrs/errors.py +46 -0
  5. thds/attrs_utils/defaults.py +31 -0
  6. thds/attrs_utils/docs.py +75 -0
  7. thds/attrs_utils/empty.py +146 -0
  8. thds/attrs_utils/isinstance/__init__.py +4 -0
  9. thds/attrs_utils/isinstance/check.py +107 -0
  10. thds/attrs_utils/isinstance/registry.py +13 -0
  11. thds/attrs_utils/isinstance/util.py +106 -0
  12. thds/attrs_utils/jsonschema/__init__.py +11 -0
  13. thds/attrs_utils/jsonschema/constructors.py +90 -0
  14. thds/attrs_utils/jsonschema/jsonschema.py +326 -0
  15. thds/attrs_utils/jsonschema/str_formats.py +109 -0
  16. thds/attrs_utils/jsonschema/util.py +94 -0
  17. thds/attrs_utils/py.typed +0 -0
  18. thds/attrs_utils/random/__init__.py +3 -0
  19. thds/attrs_utils/random/attrs.py +57 -0
  20. thds/attrs_utils/random/builtin.py +103 -0
  21. thds/attrs_utils/random/collection.py +41 -0
  22. thds/attrs_utils/random/gen.py +134 -0
  23. thds/attrs_utils/random/optional.py +13 -0
  24. thds/attrs_utils/random/registry.py +30 -0
  25. thds/attrs_utils/random/tuple.py +24 -0
  26. thds/attrs_utils/random/union.py +33 -0
  27. thds/attrs_utils/random/util.py +55 -0
  28. thds/attrs_utils/recursion.py +48 -0
  29. thds/attrs_utils/registry.py +40 -0
  30. thds/attrs_utils/type_cache.py +110 -0
  31. thds/attrs_utils/type_recursion.py +168 -0
  32. thds/attrs_utils/type_utils.py +180 -0
  33. thds_attrs_utils-1.6.20251103154246.dist-info/METADATA +230 -0
  34. thds_attrs_utils-1.6.20251103154246.dist-info/RECORD +36 -0
  35. thds_attrs_utils-1.6.20251103154246.dist-info/WHEEL +5 -0
  36. 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
+ ```