thds.atacama 0.0.1__py3-none-any.whl → 1.0.20250123022552__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.
Potentially problematic release.
This version of thds.atacama might be problematic. Click here for more details.
- thds/atacama/__init__.py +5 -0
- thds/atacama/_attrs.py +55 -0
- thds/atacama/_cache.py +37 -0
- thds/atacama/_config.py +42 -0
- thds/atacama/_generic_dispatch.py +64 -0
- thds/atacama/_meta.py +17 -0
- thds/atacama/custom_fields.py +25 -0
- thds/atacama/field_transforms.py +17 -0
- thds/atacama/fields.py +120 -0
- thds/atacama/generators.py +11 -0
- thds/atacama/leaf.py +127 -0
- thds/atacama/meta.json +8 -0
- thds/atacama/nonempty.py +38 -0
- thds/atacama/py.typed +0 -0
- thds/atacama/schemas.py +228 -0
- thds.atacama-1.0.20250123022552.dist-info/METADATA +381 -0
- thds.atacama-1.0.20250123022552.dist-info/RECORD +19 -0
- {thds.atacama-0.0.1.dist-info → thds.atacama-1.0.20250123022552.dist-info}/WHEEL +1 -1
- thds.atacama-1.0.20250123022552.dist-info/top_level.txt +1 -0
- thds.atacama-0.0.1.dist-info/METADATA +0 -8
- thds.atacama-0.0.1.dist-info/RECORD +0 -4
- thds.atacama-0.0.1.dist-info/top_level.txt +0 -1
thds/atacama/__init__.py
ADDED
thds/atacama/_attrs.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import typing as ty
|
|
2
|
+
|
|
3
|
+
import attrs
|
|
4
|
+
import marshmallow
|
|
5
|
+
|
|
6
|
+
T = ty.TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_attrs_post_load(attrs_class: ty.Type[T]):
|
|
10
|
+
@marshmallow.post_load
|
|
11
|
+
def post_load(self, data: dict, **_kw) -> T:
|
|
12
|
+
return attrs_class(**data)
|
|
13
|
+
|
|
14
|
+
return post_load
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_attrs_field_default(
|
|
18
|
+
field: "attrs.Attribute[object]",
|
|
19
|
+
) -> object:
|
|
20
|
+
# copyright Desert contributors - see LICENSE_desert.md
|
|
21
|
+
if field.default == attrs.NOTHING:
|
|
22
|
+
return marshmallow.missing
|
|
23
|
+
if isinstance(field.default, attrs.Factory): # type: ignore[arg-type]
|
|
24
|
+
# attrs specifically doesn't support this so as to support the
|
|
25
|
+
# primary use case.
|
|
26
|
+
# https://github.com/python-attrs/attrs/blob/38580632ceac1cd6e477db71e1d190a4130beed4/src/attr/__init__.pyi#L63-L65
|
|
27
|
+
if field.default.takes_self: # type: ignore[attr-defined]
|
|
28
|
+
return attrs.NOTHING
|
|
29
|
+
return field.default.factory # type: ignore[attr-defined]
|
|
30
|
+
return field.default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Attribute(ty.NamedTuple):
|
|
34
|
+
name: str
|
|
35
|
+
type: type
|
|
36
|
+
init: bool
|
|
37
|
+
default: object
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def yield_attributes(attrs_class: type) -> ty.Iterator[Attribute]:
|
|
41
|
+
hints = ty.get_type_hints(attrs_class)
|
|
42
|
+
for attribute in attrs.fields(attrs_class):
|
|
43
|
+
yield Attribute(
|
|
44
|
+
attribute.name,
|
|
45
|
+
hints.get(attribute.name, attribute.type),
|
|
46
|
+
attribute.init,
|
|
47
|
+
_get_attrs_field_default(attribute),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_attrs_class(cls: type) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
return bool(attrs.fields(cls))
|
|
54
|
+
except TypeError:
|
|
55
|
+
return False
|
thds/atacama/_cache.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""A simple equality-based cache for schema generation."""
|
|
2
|
+
import typing as ty
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from functools import wraps
|
|
5
|
+
|
|
6
|
+
import marshmallow
|
|
7
|
+
|
|
8
|
+
GenSchema = ty.TypeVar("GenSchema", bound=ty.Callable[..., ty.Type[marshmallow.Schema]])
|
|
9
|
+
GenSchemaCachingDeco = ty.Callable[[GenSchema], GenSchema]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def attrs_schema_cache() -> GenSchemaCachingDeco:
|
|
13
|
+
"""Allows sharing an attrs schema cache across multiple schema generators."""
|
|
14
|
+
schema_name_onto_arguments_and_schema: ty.Dict[
|
|
15
|
+
str, ty.List[ty.Tuple[ty.Tuple[tuple, dict], ty.Type[marshmallow.Schema]]]
|
|
16
|
+
] = defaultdict(list)
|
|
17
|
+
|
|
18
|
+
def attrs_schema_cache_deco(gen_schema: GenSchema) -> GenSchema:
|
|
19
|
+
@wraps(gen_schema)
|
|
20
|
+
def caching_gen_schema(*args, **kwargs):
|
|
21
|
+
# the following two lines are purely an optimization,
|
|
22
|
+
# so we don't have to search through all possible schemas generated.
|
|
23
|
+
schema_typename = str(args[0])
|
|
24
|
+
args_and_schemas = schema_name_onto_arguments_and_schema[schema_typename]
|
|
25
|
+
|
|
26
|
+
arguments = (args, kwargs)
|
|
27
|
+
for schema_arguments, schema in args_and_schemas:
|
|
28
|
+
if schema_arguments == arguments:
|
|
29
|
+
return schema
|
|
30
|
+
|
|
31
|
+
schema = gen_schema(*args, **kwargs)
|
|
32
|
+
schema_name_onto_arguments_and_schema[schema_typename].append((arguments, schema))
|
|
33
|
+
return schema
|
|
34
|
+
|
|
35
|
+
return ty.cast(GenSchema, caching_gen_schema)
|
|
36
|
+
|
|
37
|
+
return attrs_schema_cache_deco
|
thds/atacama/_config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Allow recursive control of Schema generation.
|
|
2
|
+
|
|
3
|
+
If you're looking for Schema load/dump behavior, that belongs to
|
|
4
|
+
Marshmallow itself and can consequently be configured via _meta.py.
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
import typing as ty
|
|
8
|
+
|
|
9
|
+
from thds.core.stack_context import StackContext # this is our only 'dependency' on core.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _GenConfig(ty.NamedTuple):
|
|
13
|
+
"""Do not construct these directly; they are an implementation detail and subject to change."""
|
|
14
|
+
|
|
15
|
+
require_all: bool
|
|
16
|
+
schema_name_suffix: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def config(require_all: bool = False, schema_name_suffix: str = "") -> _GenConfig:
|
|
20
|
+
"""Create a Schema Generation Config.
|
|
21
|
+
|
|
22
|
+
:param require_all: The Schema will enforce `required` for all
|
|
23
|
+
attributes on load. This can also be used to generate a
|
|
24
|
+
dump-only schema that accurately describes the way that all
|
|
25
|
+
attributes are 'require_all' to be present upon dump, since
|
|
26
|
+
OpenAPI and JSON schema do not provide a way to distinguish
|
|
27
|
+
between the semantics of "input required" and "output
|
|
28
|
+
require_all".
|
|
29
|
+
|
|
30
|
+
:param schema_name_suffix: does what it says on the tin. Sometimes
|
|
31
|
+
you want to generate a different Schema from the same class and
|
|
32
|
+
you don't want the generated suffixes that Marshmallow gives
|
|
33
|
+
you.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
return _GenConfig(require_all=require_all, schema_name_suffix=schema_name_suffix)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
PerGenerationConfigContext = StackContext(
|
|
40
|
+
"atacama-per-generation-config-context",
|
|
41
|
+
config(),
|
|
42
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# portions copyright Desert contributors - see LICENSE_desert.md
|
|
2
|
+
import collections
|
|
3
|
+
import typing as ty
|
|
4
|
+
|
|
5
|
+
import typing_inspect
|
|
6
|
+
|
|
7
|
+
NoneType = type(None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generic_types_dispatch(
|
|
11
|
+
sequence_handler,
|
|
12
|
+
set_handler,
|
|
13
|
+
tuple_handler,
|
|
14
|
+
mapping_handler,
|
|
15
|
+
optional_handler,
|
|
16
|
+
union_handler,
|
|
17
|
+
newtype_handler,
|
|
18
|
+
fallthrough_handler,
|
|
19
|
+
):
|
|
20
|
+
def type_discriminator(typ: ty.Type):
|
|
21
|
+
if origin := typing_inspect.get_origin(typ) or ty.get_origin(
|
|
22
|
+
typ
|
|
23
|
+
): # in case typing_inspect fails
|
|
24
|
+
# each of these internal calls to field_for_schema for a Generic is recursive
|
|
25
|
+
arguments = typing_inspect.get_args(typ, True)
|
|
26
|
+
|
|
27
|
+
if origin in (
|
|
28
|
+
list,
|
|
29
|
+
ty.List,
|
|
30
|
+
ty.Sequence,
|
|
31
|
+
ty.MutableSequence,
|
|
32
|
+
collections.abc.Sequence,
|
|
33
|
+
collections.abc.MutableSequence,
|
|
34
|
+
):
|
|
35
|
+
return sequence_handler(arguments[0])
|
|
36
|
+
if origin in (set, ty.Set, ty.MutableSet):
|
|
37
|
+
return set_handler(arguments[0])
|
|
38
|
+
if origin in (tuple, ty.Tuple) and Ellipsis not in arguments:
|
|
39
|
+
return tuple_handler(arguments, variadic=False)
|
|
40
|
+
if origin in (tuple, ty.Tuple) and Ellipsis in arguments:
|
|
41
|
+
return tuple_handler([arguments[0]], variadic=True)
|
|
42
|
+
if origin in (
|
|
43
|
+
dict,
|
|
44
|
+
ty.Dict,
|
|
45
|
+
ty.Mapping,
|
|
46
|
+
ty.MutableMapping,
|
|
47
|
+
collections.abc.Mapping,
|
|
48
|
+
collections.abc.MutableMapping,
|
|
49
|
+
):
|
|
50
|
+
return mapping_handler(arguments[0], arguments[1])
|
|
51
|
+
if typing_inspect.is_optional_type(typ):
|
|
52
|
+
non_none_subtypes = tuple(t for t in arguments if t is not NoneType)
|
|
53
|
+
return optional_handler(non_none_subtypes)
|
|
54
|
+
if typing_inspect.is_union_type(typ):
|
|
55
|
+
return union_handler(arguments)
|
|
56
|
+
|
|
57
|
+
newtype_supertype = getattr(typ, "__supertype__", None)
|
|
58
|
+
if newtype_supertype and typing_inspect.is_new_type(typ):
|
|
59
|
+
# this is just an unwrapping step.
|
|
60
|
+
return newtype_handler(newtype_supertype)
|
|
61
|
+
|
|
62
|
+
return fallthrough_handler(typ)
|
|
63
|
+
|
|
64
|
+
return type_discriminator
|
thds/atacama/_meta.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Meta is a Marshmallow-specific concept and is a way of
|
|
2
|
+
configuring Schema load/dump behavior recursively.
|
|
3
|
+
|
|
4
|
+
We have an additional layer of configuration for Schema _generation_,
|
|
5
|
+
and that lives in _config.py.
|
|
6
|
+
"""
|
|
7
|
+
import typing as ty
|
|
8
|
+
|
|
9
|
+
SchemaMeta = ty.NewType("SchemaMeta", type)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_META_DEFAULTS = dict(ordered=True)
|
|
13
|
+
# We see no reason to ever throw away the order defined by the programmer.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def meta(**meta) -> SchemaMeta:
|
|
17
|
+
return type("Meta", (), {**_META_DEFAULTS, **meta}) # type: ignore
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Support custom fields that seem necessary to us."""
|
|
2
|
+
import typing as ty
|
|
3
|
+
|
|
4
|
+
import marshmallow as ma
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Set(ma.fields.List):
|
|
8
|
+
"""Marshmallow is dragging their feet on implementing a Set Field.
|
|
9
|
+
|
|
10
|
+
https://github.com/marshmallow-code/marshmallow/issues/1549
|
|
11
|
+
|
|
12
|
+
But we can implement a simple version that basically works.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def _serialize(self, value, attr, obj, **kwargs) -> ty.Union[ty.List[ty.Any], None]:
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
# we run a sort because even though roundtrip doesn't
|
|
19
|
+
# guarantee the same value that was deserialized, we'd like
|
|
20
|
+
# for the serialized output to be stable/deterministic.
|
|
21
|
+
return [self.inner._serialize(each, attr, obj, **kwargs) for each in sorted(value)]
|
|
22
|
+
|
|
23
|
+
def _deserialize(self, value, attr, data, **kwargs) -> ty.Set[ty.Any]: # type: ignore
|
|
24
|
+
val = super()._deserialize(value, attr, data, **kwargs)
|
|
25
|
+
return set(val)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import typing as ty
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
|
|
4
|
+
import marshmallow # type: ignore
|
|
5
|
+
|
|
6
|
+
FieldTransform = ty.Callable[[marshmallow.fields.Field], marshmallow.fields.Field]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_field_xfs(
|
|
10
|
+
field_transforms: ty.Sequence[FieldTransform], fields: ty.Dict[str, marshmallow.fields.Field]
|
|
11
|
+
) -> ty.Dict[str, marshmallow.fields.Field]:
|
|
12
|
+
def apply_all(field):
|
|
13
|
+
for fxf in field_transforms:
|
|
14
|
+
field = fxf(deepcopy(field))
|
|
15
|
+
return field
|
|
16
|
+
|
|
17
|
+
return {name: apply_all(field) for name, field in fields.items()}
|
thds/atacama/fields.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# portions copyright Desert contributors - see LICENSE_desert.md
|
|
2
|
+
import enum
|
|
3
|
+
import typing as ty
|
|
4
|
+
from functools import partial
|
|
5
|
+
|
|
6
|
+
import marshmallow # type: ignore
|
|
7
|
+
import typing_inspect # type: ignore
|
|
8
|
+
|
|
9
|
+
from . import custom_fields
|
|
10
|
+
from ._generic_dispatch import generic_types_dispatch
|
|
11
|
+
from .leaf import LeafTypeMapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VariadicTuple(marshmallow.fields.List):
|
|
15
|
+
"""Homogenous tuple with variable number of entries."""
|
|
16
|
+
|
|
17
|
+
def _deserialize(self, *args: object, **kwargs: object) -> ty.Tuple[object, ...]: # type: ignore[override]
|
|
18
|
+
return tuple(super()._deserialize(*args, **kwargs))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
T = ty.TypeVar("T")
|
|
22
|
+
NoneType = type(None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# most kwargs for a field belong to the surrounding context,
|
|
26
|
+
# e.g. the name and the aggregation that this field lives in.
|
|
27
|
+
# Therefore, each can be consumed separately from the recursion that happens here.
|
|
28
|
+
# One exception is for an Optional field, which is a 'discovery' of allow_none
|
|
29
|
+
# that we could not previously have derived.
|
|
30
|
+
# The other two exceptions are 'validate', which is applied according to business requirements
|
|
31
|
+
# that cannot be derived from the context of the schema, and 'error_messages', which is the same.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_field(
|
|
35
|
+
leaf_types: LeafTypeMapping,
|
|
36
|
+
schema_generator: ty.Callable[[type], ty.Type[marshmallow.Schema]],
|
|
37
|
+
typ: type,
|
|
38
|
+
field_kwargs: ty.Mapping[str, ty.Any] = dict(), # noqa: [B006]
|
|
39
|
+
) -> marshmallow.fields.Field:
|
|
40
|
+
"""Return a Marshmallow Field or Schema.
|
|
41
|
+
|
|
42
|
+
Return a Field if a leaf type can be resolved.or if a leaf type
|
|
43
|
+
can be 'unwrapped' from a Generic or other wrapper type.
|
|
44
|
+
|
|
45
|
+
If no leaf type can be resolved, attempts to construct a Nested Schema.
|
|
46
|
+
|
|
47
|
+
Derives certain Field keyword arguments from the unwrapping process.
|
|
48
|
+
"""
|
|
49
|
+
# this is the base case recursively - a known leaf type.
|
|
50
|
+
if typ in leaf_types:
|
|
51
|
+
return leaf_types[typ](**field_kwargs)
|
|
52
|
+
|
|
53
|
+
gen_field = partial(generate_field, leaf_types, schema_generator)
|
|
54
|
+
# all recursive calls from here on out
|
|
55
|
+
|
|
56
|
+
def prefer_field_kwargs(**kwargs):
|
|
57
|
+
"""In a couple of cases, we generate some Field kwargs, but we
|
|
58
|
+
also want to prefer whatever may have been manually specified.
|
|
59
|
+
"""
|
|
60
|
+
return {**kwargs, **field_kwargs}
|
|
61
|
+
|
|
62
|
+
def tuple_handler(types: ty.Type, variadic: bool):
|
|
63
|
+
if not variadic:
|
|
64
|
+
return marshmallow.fields.Tuple( # type: ignore[no-untyped-call]
|
|
65
|
+
tuple(gen_field(typ) for typ in types),
|
|
66
|
+
**field_kwargs,
|
|
67
|
+
)
|
|
68
|
+
return VariadicTuple(gen_field(types[0]), **field_kwargs)
|
|
69
|
+
|
|
70
|
+
def union_handler(types, **field_kwargs):
|
|
71
|
+
import marshmallow_union # type: ignore
|
|
72
|
+
|
|
73
|
+
return marshmallow_union.Union([gen_field(subtyp) for subtyp in types], **field_kwargs)
|
|
74
|
+
|
|
75
|
+
def optional_handler(non_none_subtypes):
|
|
76
|
+
# Optionals are a special case of Union. _if_ the union is
|
|
77
|
+
# fully coalesced, we can treat it as a simple field.
|
|
78
|
+
if len(non_none_subtypes) == 1:
|
|
79
|
+
# Treat single-argument optional types as a field with a None default
|
|
80
|
+
return gen_field(non_none_subtypes[0], prefer_field_kwargs(allow_none=True))
|
|
81
|
+
# Otherwise, we must fall back to handling it as a Union.
|
|
82
|
+
return union_handler(non_none_subtypes, **prefer_field_kwargs(allow_none=True))
|
|
83
|
+
|
|
84
|
+
def fallthrough_handler(typ: ty.Type):
|
|
85
|
+
if type(typ) is enum.EnumMeta:
|
|
86
|
+
import marshmallow_enum # type: ignore
|
|
87
|
+
|
|
88
|
+
return marshmallow_enum.EnumField(typ, **field_kwargs)
|
|
89
|
+
|
|
90
|
+
# Nested dataclasses
|
|
91
|
+
forward_reference = typing_inspect.get_forward_arg(typ)
|
|
92
|
+
if forward_reference:
|
|
93
|
+
# TODO this is not getting hit - I think because typing_inspect.get_args
|
|
94
|
+
# resolves all ForwardRefs that live inside any kind of Generic type, including Unions,
|
|
95
|
+
# turning them into _not_ ForwardRefs anymore.a
|
|
96
|
+
return marshmallow.fields.Nested(forward_reference, **field_kwargs)
|
|
97
|
+
# by using a lambda here, we can provide full support for self-recursive schemas
|
|
98
|
+
# the same way Marshmallow itself does:
|
|
99
|
+
# https://marshmallow.readthedocs.io/en/stable/nesting.html#nesting-a-schema-within-itself
|
|
100
|
+
#
|
|
101
|
+
# One disadvantage is that we defer errors until runtime, so it may be worth considering
|
|
102
|
+
# whether we should find a different way of 'discovering' mutually-recursive types
|
|
103
|
+
try:
|
|
104
|
+
nested_schema = schema_generator(typ)
|
|
105
|
+
except RecursionError:
|
|
106
|
+
nested_schema = lambda: schema_generator(typ) # type: ignore # noqa: E731
|
|
107
|
+
return marshmallow.fields.Nested(nested_schema, **field_kwargs)
|
|
108
|
+
|
|
109
|
+
return generic_types_dispatch(
|
|
110
|
+
sequence_handler=lambda typ: marshmallow.fields.List(gen_field(typ), **field_kwargs),
|
|
111
|
+
set_handler=lambda typ: custom_fields.Set(gen_field(typ), **field_kwargs),
|
|
112
|
+
tuple_handler=tuple_handler,
|
|
113
|
+
mapping_handler=lambda keytype, valtype: marshmallow.fields.Dict(
|
|
114
|
+
**prefer_field_kwargs(keys=gen_field(keytype), values=gen_field(valtype))
|
|
115
|
+
),
|
|
116
|
+
optional_handler=optional_handler,
|
|
117
|
+
union_handler=union_handler,
|
|
118
|
+
newtype_handler=lambda newtype_supertype: gen_field(newtype_supertype, field_kwargs),
|
|
119
|
+
fallthrough_handler=fallthrough_handler,
|
|
120
|
+
)(typ)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Built-in generators recommended for use."""
|
|
2
|
+
from ._cache import attrs_schema_cache
|
|
3
|
+
from ._meta import meta
|
|
4
|
+
from .nonempty import nonempty_validator_xf
|
|
5
|
+
from .schemas import SchemaGenerator
|
|
6
|
+
|
|
7
|
+
neo = SchemaGenerator(meta(), [nonempty_validator_xf], cache=attrs_schema_cache())
|
|
8
|
+
"""Non-Empty, Ordered - the preferred default API."""
|
|
9
|
+
|
|
10
|
+
ordered = SchemaGenerator(meta(), list(), cache=attrs_schema_cache())
|
|
11
|
+
"""Ordered, but allows empty values for required fields. Prefer neo for new usage."""
|
thds/atacama/leaf.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# portions copyright Desert contributors - see LICENSE_desert.md
|
|
2
|
+
import datetime
|
|
3
|
+
import decimal
|
|
4
|
+
import typing as ty
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
import marshmallow
|
|
8
|
+
import typing_inspect
|
|
9
|
+
from typing_extensions import Protocol
|
|
10
|
+
|
|
11
|
+
# Default leaf types used by `generate_fields`.
|
|
12
|
+
# It is possible to swap in your own definitions via a SchemaGenerator.
|
|
13
|
+
LeafType = ty.Union[type, ty.Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LeafTypeMapping(Protocol):
|
|
17
|
+
def __contains__(self, __key: LeafType) -> bool:
|
|
18
|
+
... # pragma: nocover
|
|
19
|
+
|
|
20
|
+
def __getitem__(self, __key: LeafType) -> ty.Type[marshmallow.fields.Field]:
|
|
21
|
+
... # pragma: nocover
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FieldType = ty.Type[marshmallow.fields.Field]
|
|
25
|
+
FieldT = ty.TypeVar("FieldT", bound=FieldType)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _field_with_default_kwargs(field: FieldT, **default_kwargs) -> FieldT:
|
|
29
|
+
def fake_field(**kwargs):
|
|
30
|
+
combined = {**default_kwargs, **kwargs}
|
|
31
|
+
return field(**combined)
|
|
32
|
+
|
|
33
|
+
return ty.cast(FieldT, fake_field)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def allow_already_parsed(typ: ty.Type, mm_field: FieldT) -> FieldT:
|
|
37
|
+
"""For certain types, it's quite common to want to be able to pass
|
|
38
|
+
an already-deserialized version of that item without error,
|
|
39
|
+
especially if you want to be able to use a Schema against both a
|
|
40
|
+
database abstraction (e.g. SQLAlchemy) and an API.
|
|
41
|
+
|
|
42
|
+
E.g., there's no need to throw an error if you're expecting a
|
|
43
|
+
datetime string so you can turn it into a datetime, but you get a
|
|
44
|
+
datetime itself.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def _deserialize(self, value, attr, data, **kwargs):
|
|
48
|
+
if isinstance(value, typ):
|
|
49
|
+
return value
|
|
50
|
+
return mm_field._deserialize(self, value, attr, data, **kwargs)
|
|
51
|
+
|
|
52
|
+
return ty.cast(
|
|
53
|
+
FieldT,
|
|
54
|
+
type(
|
|
55
|
+
mm_field.__name__ + "AllowsAlreadyDeserialized",
|
|
56
|
+
(mm_field,), # inherits from this field type
|
|
57
|
+
dict(_deserialize=_deserialize),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
NATIVE_TO_MARSHMALLOW: LeafTypeMapping = {
|
|
63
|
+
float: marshmallow.fields.Float,
|
|
64
|
+
int: marshmallow.fields.Integer,
|
|
65
|
+
str: marshmallow.fields.String,
|
|
66
|
+
bool: marshmallow.fields.Boolean,
|
|
67
|
+
datetime.datetime: allow_already_parsed(datetime.datetime, marshmallow.fields.DateTime),
|
|
68
|
+
datetime.time: marshmallow.fields.Time,
|
|
69
|
+
datetime.timedelta: allow_already_parsed(datetime.timedelta, marshmallow.fields.TimeDelta),
|
|
70
|
+
datetime.date: allow_already_parsed(datetime.date, marshmallow.fields.Date),
|
|
71
|
+
decimal.Decimal: allow_already_parsed(decimal.Decimal, marshmallow.fields.Decimal),
|
|
72
|
+
uuid.UUID: marshmallow.fields.UUID,
|
|
73
|
+
ty.Union[int, float]: marshmallow.fields.Number,
|
|
74
|
+
ty.Any: _field_with_default_kwargs(marshmallow.fields.Raw, allow_none=True),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
OptFieldType = ty.Optional[FieldType]
|
|
79
|
+
TypeHandler = ty.Callable[[LeafType], OptFieldType]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DynamicLeafTypeMapping(LeafTypeMapping):
|
|
83
|
+
"""May be nested infinitely inside one another, with the outer one taking priority."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, base_map: LeafTypeMapping, inorder_type_handlers: ty.Sequence[TypeHandler]):
|
|
86
|
+
self.base_map = base_map
|
|
87
|
+
self.inorder_type_handlers = inorder_type_handlers
|
|
88
|
+
|
|
89
|
+
def _try_handlers(self, obj: LeafType) -> OptFieldType:
|
|
90
|
+
for handler in self.inorder_type_handlers:
|
|
91
|
+
field_constructor = handler(obj)
|
|
92
|
+
if field_constructor is not None:
|
|
93
|
+
return field_constructor
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def __contains__(self, obj: LeafType) -> bool:
|
|
97
|
+
if self._try_handlers(obj):
|
|
98
|
+
return True
|
|
99
|
+
return obj in self.base_map
|
|
100
|
+
|
|
101
|
+
def __getitem__(self, obj: object) -> FieldType:
|
|
102
|
+
field = self._try_handlers(obj)
|
|
103
|
+
if field is not None:
|
|
104
|
+
return field
|
|
105
|
+
return self.base_map[obj]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def handle_literals(lt: LeafType) -> OptFieldType:
|
|
109
|
+
if typing_inspect.is_literal_type(lt):
|
|
110
|
+
values = typing_inspect.get_args(lt)
|
|
111
|
+
validator = marshmallow.validate.OneOf(values)
|
|
112
|
+
# the validator is the same no matter what - set membership/equality
|
|
113
|
+
|
|
114
|
+
types = [type(val) for val in values]
|
|
115
|
+
if all(typ == types[0] for typ in types):
|
|
116
|
+
# if we can narrow down to a single shared leaf type, use it:
|
|
117
|
+
underlying_type = types[0]
|
|
118
|
+
if underlying_type in NATIVE_TO_MARSHMALLOW:
|
|
119
|
+
return _field_with_default_kwargs(
|
|
120
|
+
NATIVE_TO_MARSHMALLOW[underlying_type], validate=validator
|
|
121
|
+
)
|
|
122
|
+
# otherwise use the Raw type with the OneOf validator
|
|
123
|
+
return _field_with_default_kwargs(marshmallow.fields.Raw, validate=validator)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
AtacamaBaseLeafTypeMapping = DynamicLeafTypeMapping(NATIVE_TO_MARSHMALLOW, [handle_literals])
|
thds/atacama/meta.json
ADDED
thds/atacama/nonempty.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import typing as ty
|
|
2
|
+
|
|
3
|
+
import marshmallow as ma
|
|
4
|
+
import marshmallow.validate as mav
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _append_validator(validator: ty.Callable, field: ma.fields.Field) -> ma.fields.Field:
|
|
8
|
+
"""Marshmallow builds in arrays of validators, so this modifies the array"""
|
|
9
|
+
if not field.validate:
|
|
10
|
+
field.validate = validator
|
|
11
|
+
field.validators.append(validator)
|
|
12
|
+
return field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def nonempty_validator_xf(field: ma.fields.Field) -> ma.fields.Field:
|
|
16
|
+
"""Objects which have a length also have a known, meaningful default, e.g. empty string, empty list, empty dict.
|
|
17
|
+
|
|
18
|
+
If you are requiring that an attribute have a value provided,
|
|
19
|
+
then you're also stating that there is no meaningful default
|
|
20
|
+
value for that attribute.
|
|
21
|
+
|
|
22
|
+
If there is a meaningful default for the type, but no meaningful default for the attribute,
|
|
23
|
+
then you should never be accepting the default value for that attribute.
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def nonempty_validator(value: ty.Any) -> bool:
|
|
28
|
+
try:
|
|
29
|
+
if not len(value):
|
|
30
|
+
raise mav.ValidationError("The length for a non-defaulting field must not be zero")
|
|
31
|
+
except TypeError:
|
|
32
|
+
pass
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
if field.load_default == ma.missing:
|
|
36
|
+
# this field should disallow 'empty'/falsy values, such as empty strings, empty lists, etc.
|
|
37
|
+
return _append_validator(nonempty_validator, field)
|
|
38
|
+
return field
|
thds/atacama/py.typed
ADDED
|
File without changes
|
thds/atacama/schemas.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import typing as ty
|
|
2
|
+
|
|
3
|
+
import marshmallow # type: ignore
|
|
4
|
+
from typing_extensions import Protocol
|
|
5
|
+
|
|
6
|
+
from ._attrs import generate_attrs_post_load, is_attrs_class, yield_attributes
|
|
7
|
+
from ._cache import GenSchemaCachingDeco
|
|
8
|
+
from ._config import PerGenerationConfigContext, _GenConfig
|
|
9
|
+
from ._meta import SchemaMeta
|
|
10
|
+
from .field_transforms import FieldTransform, apply_field_xfs
|
|
11
|
+
from .fields import generate_field
|
|
12
|
+
from .leaf import AtacamaBaseLeafTypeMapping, LeafTypeMapping
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _set_default(default: object = marshmallow.missing) -> ty.Dict[str, ty.Any]:
|
|
16
|
+
"""Generate the appropriate Marshmallow keyword arguments depending on whether the default is missing or not"""
|
|
17
|
+
config = PerGenerationConfigContext()
|
|
18
|
+
if default is not marshmallow.missing:
|
|
19
|
+
# we have a default
|
|
20
|
+
field_kwargs = dict(dump_default=default, load_default=default)
|
|
21
|
+
if config.require_all:
|
|
22
|
+
field_kwargs.pop("load_default") # can't combine load_default with required
|
|
23
|
+
field_kwargs["required"] = True
|
|
24
|
+
return field_kwargs
|
|
25
|
+
return dict(required=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_schema(a: ty.Any):
|
|
29
|
+
return isinstance(a, marshmallow.Schema) or (
|
|
30
|
+
isinstance(a, type) and issubclass(a, marshmallow.Schema)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NamedFieldsSchemaGenerator(Protocol):
|
|
35
|
+
def __call__(self, __attrs_class: type, **__fields: "NamedField") -> ty.Type[marshmallow.Schema]:
|
|
36
|
+
... # pragma: nocover
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _NestedSchemaGenerator:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
sg: NamedFieldsSchemaGenerator,
|
|
43
|
+
field_kwargs: ty.Mapping[str, ty.Any],
|
|
44
|
+
fields: "ty.Mapping[str, NamedField]",
|
|
45
|
+
):
|
|
46
|
+
self._schema_generator = sg
|
|
47
|
+
self.field_kwargs = field_kwargs
|
|
48
|
+
self._fields = fields
|
|
49
|
+
# to be used by the discriminator
|
|
50
|
+
|
|
51
|
+
def __call__(self, typ: type) -> ty.Type[marshmallow.Schema]:
|
|
52
|
+
return self._schema_generator(typ, **self._fields)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _PartialField(ty.NamedTuple):
|
|
56
|
+
field_kwargs: ty.Mapping[str, ty.Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
NamedField = ty.Union[
|
|
60
|
+
marshmallow.fields.Field,
|
|
61
|
+
_NestedSchemaGenerator,
|
|
62
|
+
_PartialField,
|
|
63
|
+
ty.Type[marshmallow.Schema],
|
|
64
|
+
marshmallow.Schema,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SchemaGenerator:
|
|
69
|
+
"""A Marshmallow Schema Generator.
|
|
70
|
+
|
|
71
|
+
Recursively generates Schemas and their Fields from attrs classes
|
|
72
|
+
and their attributes, allowing selective overrides at every level
|
|
73
|
+
of the recursive type.
|
|
74
|
+
|
|
75
|
+
When we generate a Marshmallow Field, about half of the 'work' is
|
|
76
|
+
something that can logically be derived from the context (e.g.,
|
|
77
|
+
does the field have a default, is it required, is it a list, etc)
|
|
78
|
+
and the other half is specific to the use case (do I want
|
|
79
|
+
additional validators, is it load_only, etc).
|
|
80
|
+
|
|
81
|
+
We aim to make it easy to layer in the 'specific' stuff while
|
|
82
|
+
keeping the 'given' stuff from the context, to reduce accidents
|
|
83
|
+
and having to repeat yourself.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
meta: SchemaMeta,
|
|
89
|
+
field_transforms: ty.Sequence[FieldTransform],
|
|
90
|
+
*,
|
|
91
|
+
leaf_types: LeafTypeMapping = AtacamaBaseLeafTypeMapping,
|
|
92
|
+
cache: ty.Optional[GenSchemaCachingDeco] = None,
|
|
93
|
+
):
|
|
94
|
+
self._meta = meta
|
|
95
|
+
self._field_transforms = field_transforms
|
|
96
|
+
self._leaf_types = leaf_types
|
|
97
|
+
if cache:
|
|
98
|
+
self.generate = cache(self.generate) # type: ignore
|
|
99
|
+
|
|
100
|
+
def __call__(
|
|
101
|
+
self,
|
|
102
|
+
__attrs_class: type,
|
|
103
|
+
__config: ty.Optional[_GenConfig] = None,
|
|
104
|
+
**named_fields: NamedField,
|
|
105
|
+
) -> ty.Type[marshmallow.Schema]:
|
|
106
|
+
"""Generate a Schema class from an attrs class.
|
|
107
|
+
|
|
108
|
+
High-level convenience API that allows for using keyword arguments.
|
|
109
|
+
"""
|
|
110
|
+
return self.generate(__attrs_class, config=__config, fields=named_fields)
|
|
111
|
+
|
|
112
|
+
def generate(
|
|
113
|
+
self,
|
|
114
|
+
attrs_class: type,
|
|
115
|
+
*,
|
|
116
|
+
fields: ty.Mapping[str, NamedField] = dict(), # noqa: B006
|
|
117
|
+
config: ty.Optional[_GenConfig] = None,
|
|
118
|
+
schema_base_classes: ty.Tuple[ty.Type[marshmallow.Schema], ...] = (marshmallow.Schema,),
|
|
119
|
+
) -> ty.Type[marshmallow.Schema]:
|
|
120
|
+
"""Low-level API allowing for future keyword arguments that do not overlap with NamedFields.
|
|
121
|
+
|
|
122
|
+
May include caching if the SchemaGenerator is so-equipped.
|
|
123
|
+
"""
|
|
124
|
+
assert is_attrs_class(attrs_class), (
|
|
125
|
+
f"Object {attrs_class} (of type {type(attrs_class)}) is not an attrs class. "
|
|
126
|
+
"If this has been entered recursively, it's likely that you need a custom leaf type definition."
|
|
127
|
+
)
|
|
128
|
+
config = config or PerGenerationConfigContext()
|
|
129
|
+
with PerGenerationConfigContext.set(config):
|
|
130
|
+
return type(
|
|
131
|
+
".".join((attrs_class.__module__, attrs_class.__name__))
|
|
132
|
+
+ f"{config.schema_name_suffix}Schema",
|
|
133
|
+
schema_base_classes,
|
|
134
|
+
dict(
|
|
135
|
+
apply_field_xfs(
|
|
136
|
+
self._field_transforms,
|
|
137
|
+
self._gen_fields(attrs_class, **fields),
|
|
138
|
+
),
|
|
139
|
+
Meta=self._meta,
|
|
140
|
+
__atacama_post_load=generate_attrs_post_load(attrs_class),
|
|
141
|
+
__generated_by_atacama=True, # not used for anything currently
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def _named_field_discriminator(self, attribute, named_field: NamedField) -> marshmallow.fields.Field:
|
|
146
|
+
"""When we are given a field name with a provided value, there are 4 possibilities."""
|
|
147
|
+
# 1. A Field. This should be plugged directly into the Schema
|
|
148
|
+
# without being touched. Recursion ends here.
|
|
149
|
+
if isinstance(named_field, marshmallow.fields.Field):
|
|
150
|
+
return named_field
|
|
151
|
+
# 2. A Schema. You may already have generated (or defined) a
|
|
152
|
+
# Schema for your type. In this case, we simply want to create
|
|
153
|
+
# a Nested field for you with the appropriate outer keyword
|
|
154
|
+
# arguments for the field, since we know whether this is
|
|
155
|
+
# optional, required, etc. Recursion will end as soon as the
|
|
156
|
+
# parts of the type that affect the field keyword arguments
|
|
157
|
+
# for Nested have been stripped and then applied to the NestedField.
|
|
158
|
+
if _is_schema(named_field):
|
|
159
|
+
return generate_field(
|
|
160
|
+
self._leaf_types,
|
|
161
|
+
lambda _s: ty.cast(ty.Type[marshmallow.Schema], named_field),
|
|
162
|
+
attribute.type,
|
|
163
|
+
_set_default(attribute.default),
|
|
164
|
+
)
|
|
165
|
+
# 3. A nested Schema Generator, with inner keyword
|
|
166
|
+
# arguments. This would be used in the case where you want the
|
|
167
|
+
# outer keyword arguments for Nested to be generated by the
|
|
168
|
+
# current generator, and the nested Schema itself generated
|
|
169
|
+
# from the type, but you want to change the SchemaGenerator
|
|
170
|
+
# context (either the Meta or the field_transforms). Recursion
|
|
171
|
+
# will continue inside the new Schema Generator
|
|
172
|
+
# provided. These are created by passing a SchemaGenerator to
|
|
173
|
+
# .nested on the current SchemaGenerator.
|
|
174
|
+
if isinstance(named_field, _NestedSchemaGenerator):
|
|
175
|
+
return generate_field(
|
|
176
|
+
self._leaf_types, named_field, attribute.type, named_field.field_kwargs
|
|
177
|
+
)
|
|
178
|
+
# 4. A partial Field with some 'inner' keyword arguments for
|
|
179
|
+
# the Field only. Recursion continues - simply adds keyword
|
|
180
|
+
# arguments to the Field being generated.
|
|
181
|
+
assert isinstance(named_field, _PartialField), (
|
|
182
|
+
"Named fields must be a Field or Schema, "
|
|
183
|
+
"or must be created with `.field` or `.nested` on a SchemaGenerator. Got: "
|
|
184
|
+
+ str(named_field)
|
|
185
|
+
)
|
|
186
|
+
return generate_field(
|
|
187
|
+
self._leaf_types,
|
|
188
|
+
self,
|
|
189
|
+
attribute.type,
|
|
190
|
+
dict(_set_default(attribute.default), **named_field.field_kwargs),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _gen_fields(
|
|
194
|
+
self, __attrs_class: type, **named_fields: NamedField
|
|
195
|
+
) -> ty.Dict[str, marshmallow.fields.Field]:
|
|
196
|
+
"""Internal helper for iterating over attrs fields and generating Marshmallow fields for each"""
|
|
197
|
+
names_onto_fields = {
|
|
198
|
+
attribute.name: (
|
|
199
|
+
generate_field(
|
|
200
|
+
self._leaf_types,
|
|
201
|
+
self,
|
|
202
|
+
attribute.type,
|
|
203
|
+
_set_default(attribute.default),
|
|
204
|
+
)
|
|
205
|
+
if attribute.name not in named_fields
|
|
206
|
+
else self._named_field_discriminator(attribute, named_fields.pop(attribute.name))
|
|
207
|
+
)
|
|
208
|
+
for attribute in yield_attributes(__attrs_class)
|
|
209
|
+
if attribute.init
|
|
210
|
+
}
|
|
211
|
+
if named_fields:
|
|
212
|
+
# This is just here to avoid people debugging mysterious issues
|
|
213
|
+
raise KeyError(
|
|
214
|
+
f"Named attribute(s) {named_fields.keys()} not found "
|
|
215
|
+
f"in the `attrs` class {__attrs_class.__class__.__name__} "
|
|
216
|
+
" - this indicates incorrect (possibly misspelled?) keyword argument(s)."
|
|
217
|
+
)
|
|
218
|
+
return names_onto_fields
|
|
219
|
+
|
|
220
|
+
def field(self, **inner_field_kwargs) -> _PartialField:
|
|
221
|
+
"""Defines a field within the context of an existing Schema and attrs type."""
|
|
222
|
+
return _PartialField(inner_field_kwargs)
|
|
223
|
+
|
|
224
|
+
def nested(self, **outer_field_kwargs) -> ty.Callable[..., _NestedSchemaGenerator]:
|
|
225
|
+
def make_nsg(**fields) -> _NestedSchemaGenerator:
|
|
226
|
+
return _NestedSchemaGenerator(self, outer_field_kwargs, fields)
|
|
227
|
+
|
|
228
|
+
return make_nsg
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: thds.atacama
|
|
3
|
+
Version: 1.0.20250123022552
|
|
4
|
+
Summary: A Marshmallow schema generator for `attrs` classes. Inspired by `desert`.
|
|
5
|
+
Author: Trilliant Health
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: marshmallow>=3.1
|
|
8
|
+
Requires-Dist: marshmallow-enum
|
|
9
|
+
Requires-Dist: marshmallow-union
|
|
10
|
+
Requires-Dist: thds.core>=1.31
|
|
11
|
+
Requires-Dist: typing-inspect>=0.9.0
|
|
12
|
+
|
|
13
|
+
# atacama
|
|
14
|
+
|
|
15
|
+
A Marshmallow schema generator for `attrs` classes.
|
|
16
|
+
|
|
17
|
+
Inspired by `desert`.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
`desert` seems mostly unmaintained. It is also surprisingly small (kudos to the authors), which makes it
|
|
22
|
+
a reasonable target for forking and maintaining.
|
|
23
|
+
|
|
24
|
+
However, we think the (widespread) practice of complecting the data class definition with its
|
|
25
|
+
serialization schema is unwise. While this is certainly DRY-er than having to rewrite the entire Schema,
|
|
26
|
+
it's (critically) not DRY at all if you ever want to have different de/serialization patterns depending
|
|
27
|
+
on the data source.
|
|
28
|
+
|
|
29
|
+
In particular, `atacama` is attempting to optimize for the space of Python application that serve APIs
|
|
30
|
+
from a database. These are common situations where serialization and deserialization may need to act
|
|
31
|
+
differently, and there's value in being able to cleanly separate those without redefining the `attrs`
|
|
32
|
+
class itself.
|
|
33
|
+
|
|
34
|
+
`cattrs` is the prior art here, which mostly dynamically defines all of its structure and unstructure
|
|
35
|
+
operations, and allows for different Converters to be used on the same `attrs` classes. However `cattrs`
|
|
36
|
+
does not bring the same level of usability as Marshmallow when it comes to various things that are
|
|
37
|
+
important for APIs. In particular, we prefer Marshmallow for its:
|
|
38
|
+
|
|
39
|
+
- validation, which we find to be more ergonomic in the Marshmallow-verse.
|
|
40
|
+
- ecosystem utilities such as OpenAPI spec generation from Marshmallow Schemas.
|
|
41
|
+
|
|
42
|
+
As of this writing, we are unaware of anything that `cattrs` can do that we cannot accomplish in
|
|
43
|
+
Marshmallow, although for performance and other reasons, there may be cases where `cattrs` remains a
|
|
44
|
+
better fit!
|
|
45
|
+
|
|
46
|
+
Thus `atacama`. It aims to provide fully dynamic Schema generation, while retaining 100% of the
|
|
47
|
+
generality offered by Marshmallow, in a form that avoids introducing complex shim APIs that no longer
|
|
48
|
+
look and feel like Marshmallow itself.
|
|
49
|
+
|
|
50
|
+
## What
|
|
51
|
+
|
|
52
|
+
`atacama` takes advantage of Python keyword arguments to provide as low-boilerplate an interface as
|
|
53
|
+
possible. Given:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
from datetime import datetime, date
|
|
57
|
+
import attrs
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@attrs.define
|
|
61
|
+
class Todo:
|
|
62
|
+
id: str
|
|
63
|
+
owner_id: str
|
|
64
|
+
created_at: datetime
|
|
65
|
+
priority: float = 0.0
|
|
66
|
+
due_on: None | date = None
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For such a simple example, let's assume the following Schema validation rules, but only for when the data
|
|
70
|
+
comes in via the API:
|
|
71
|
+
|
|
72
|
+
- `created_at` must be before the current moment
|
|
73
|
+
- `priority` must be in the range \[0.0, 10.0\]
|
|
74
|
+
- `due_on`, if present, must be before 2038, when the Unix epoch will roll over and all computers will
|
|
75
|
+
die a fiery death.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
from typing import Type
|
|
79
|
+
from atacama import neo # neo is the recommended default SchemaGenerator
|
|
80
|
+
import marshmallow as ma
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def before_now(dt: datetime) -> bool:
|
|
84
|
+
return dt <= datetime.now()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def before_unix_death(date: date):
|
|
88
|
+
return date < date(2038, 1, 19)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
TodoFromApi: Type[ma.Schema] = neo(
|
|
92
|
+
Todo,
|
|
93
|
+
created_at=neo.field(validate=before_now),
|
|
94
|
+
priority=neo.field(validate=ma.validate.Range(min=0.0, max=10.0),
|
|
95
|
+
due_on=neo.field(validate=before_unix_death),
|
|
96
|
+
)
|
|
97
|
+
TodoFromDb: Type[ma.Schema] = neo(
|
|
98
|
+
Todo,
|
|
99
|
+
created_at=neo.field(data_key='created_ts'),
|
|
100
|
+
)
|
|
101
|
+
# both of the generated Schemas are actually Schema _classes_,
|
|
102
|
+
# just like a statically defined Marshmallow class.
|
|
103
|
+
# In most cases, you'll want to instantiate an object of the class
|
|
104
|
+
# before use, e.g. `TodoFromDb().load(...)`
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Note that nothing that we have done here requires
|
|
108
|
+
|
|
109
|
+
- modifying the `Todo` class in any way.
|
|
110
|
+
- repeating any information that can be derived _from_ the `Todo` class (e.g. that `due_on` is a `date`,
|
|
111
|
+
or that it is `Optional` with a default of `None`).
|
|
112
|
+
- complecting the data source and validation/transformation for that source with the core data type
|
|
113
|
+
itself, which can easily be shared across both the database and the API.
|
|
114
|
+
|
|
115
|
+
### Recursive Schema and Field generation
|
|
116
|
+
|
|
117
|
+
The first example demonstrates what we want and why we want it, but does not prove generality for our
|
|
118
|
+
approach. Classes are by nature recursively defined, and Schemas must also be.
|
|
119
|
+
|
|
120
|
+
Happily, `atacama` supports recursive generation and recursive customization at each layer of the
|
|
121
|
+
class+`Schema`.
|
|
122
|
+
|
|
123
|
+
There are five fundamental cases for every attribute in a class which is desired to be a `Field` in a
|
|
124
|
+
Schema. Two of these have already been demonstrated. The 5 cases are the following:
|
|
125
|
+
|
|
126
|
+
1. Completely dynamic `Field` and recursive `Schema` generation.
|
|
127
|
+
|
|
128
|
+
- This is demonstrated by `id` and `owner_id` in our `Todo` example. We told `atacama` nothing about
|
|
129
|
+
them, and reasonable Marshmallow Fields with correct defaults were generated for both.
|
|
130
|
+
|
|
131
|
+
2. A customized `Field`, with recursive `Schema` generation as needed.
|
|
132
|
+
|
|
133
|
+
- This is demonstrated by `created_at`, `priority`, and `due_on` in our `Todo` example. Much information
|
|
134
|
+
can be dynamically derived from the annotations in the `Todo` class, and `atacama` will do so. However,
|
|
135
|
+
we also wished to _add_ information to the generated `Field`, and we can trivially do so by supplying
|
|
136
|
+
keyword arguments normally accepted by `Field` directly to the `field` method of our `SchemaGenerator`.
|
|
137
|
+
These keyword arguments can even technically override the keyword arguments for `Field` derived by
|
|
138
|
+
`atacama` itself, though that would in most cases be a violation of your contract with the readers of
|
|
139
|
+
your class definition and is therefore not recommended. The `Field` _type_ will still be chosen by
|
|
140
|
+
`atacama`, so if for some reason you want more control than is being offered by `atacama`, that takes
|
|
141
|
+
you to option #3:
|
|
142
|
+
|
|
143
|
+
3. Completely static `Field` definition.
|
|
144
|
+
|
|
145
|
+
- In some cases, you may wish to opt out of `atacama` entirely, starting at a given attribute. In this
|
|
146
|
+
case, simply provide a Marshmallow `Field` (which is by definition fully defined recursively), and
|
|
147
|
+
`atacama` will respect your intention by placing the `Field` directly into the `Schema` at the
|
|
148
|
+
specified point.
|
|
149
|
+
|
|
150
|
+
4. A statically defined `Schema`.
|
|
151
|
+
|
|
152
|
+
- This is similar to case 2, except that, by providing a Marshmallow `Schema` for a nested attribute, you
|
|
153
|
+
are confirming that you want `atacama` to infer the "outer" information about that attribute, including
|
|
154
|
+
that is is a `Nested` `Field`, to perform all the standard unwrapping of Generic and Union types, and
|
|
155
|
+
to assign the correct default based on your `attrs` class definition. For instance, an attribute that
|
|
156
|
+
exhibits the definition `Optional[List[YourClass]] = None` would allow you to provide a nested `Schema`
|
|
157
|
+
defining only how to handle `YourClass`, while still generating the functionality around the default
|
|
158
|
+
value None and expecting a `List` of `YourClass`.
|
|
159
|
+
- In particular, this would be an expected case when you have a need to generate a `Schema` for direct
|
|
160
|
+
deserialization of a class that is also used in a parent class and `Schema`, but where both the parent
|
|
161
|
+
and child Schema share all the same custom validation, etc. By generating the nested `Schema` and then
|
|
162
|
+
assigning it at the proper location within the parent `Schema`, you can easily reuse all of the
|
|
163
|
+
customization from the child generation.
|
|
164
|
+
|
|
165
|
+
5. A nested `Schema` _generator_.
|
|
166
|
+
|
|
167
|
+
- The most common use case for this will be when it is desirable to customize the generated `Field` of a
|
|
168
|
+
nested class. In order to provide an API that continues to privilege keyword arguments as a way of
|
|
169
|
+
'pathing' to the various parts of the `Schema`, we must first capture any keyword arguments specific to
|
|
170
|
+
the `Nested` `Field` that will be generated, and from there on we can allow you to provide names
|
|
171
|
+
pointing to attributes in the nested class.
|
|
172
|
+
- SchemaGenerators are objects created by users who wish to customize `Schema` generation in particular
|
|
173
|
+
ways. The `Meta` class within a Marshmallow `Schema` changes certain behaviors across all its fields.
|
|
174
|
+
While `atacama` provides several default generators, you may wish to create your own. Regardless, the
|
|
175
|
+
use case for providing a nested `SchemaGenerator` is more specifically where you wish to make Schemas
|
|
176
|
+
with nested Schemas that follow different rules than their parents. This is no issue with `atacama` -
|
|
177
|
+
if it finds a nested `SchemaGenerator`, it will defer nested generation from that point onward to the
|
|
178
|
+
new `SchemaGenerator` as expected. Note that, of course, the `Field` being generated for that attribute
|
|
179
|
+
will follow the rules of the _current_ SchemaGenerator, just as would happen with nested `Meta` classes
|
|
180
|
+
in nested Schemas.
|
|
181
|
+
|
|
182
|
+
What does this look like in practice? See the annotated example below, which demonstrates all 5 of these
|
|
183
|
+
possible interactions between an `attrs` class and the specific `Schema` desired by our (potentially
|
|
184
|
+
somewhat sugar-high) imaginary user:
|
|
185
|
+
|
|
186
|
+
```python 3.7
|
|
187
|
+
@attrs.define
|
|
188
|
+
class Mallow:
|
|
189
|
+
gooeyness: GooeyEnum
|
|
190
|
+
color: str = "light-brown"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@attrs.define
|
|
194
|
+
class Milk:
|
|
195
|
+
"""Just a percentage"""
|
|
196
|
+
|
|
197
|
+
fat_pct: float
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@attrs.define
|
|
201
|
+
class ChocolateIngredients:
|
|
202
|
+
cacao_src: str
|
|
203
|
+
sugar_grams: float
|
|
204
|
+
milk: ty.Optional[Milk] = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@attrs.define
|
|
208
|
+
class Chocolate:
|
|
209
|
+
brand: str
|
|
210
|
+
cacao_pct: float
|
|
211
|
+
ingredients: ty.Optional[ChocolateIngredients] = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@attrs.define
|
|
215
|
+
class GrahamCracker:
|
|
216
|
+
brand: str
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@attrs.define
|
|
220
|
+
class Smore:
|
|
221
|
+
graham_cracker: GrahamCracker
|
|
222
|
+
marshmallows: ty.List[Mallow]
|
|
223
|
+
chocolate: ty.Optional[Chocolate] = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
ChocolateIngredientsFromApiSchema = atacama.neo(
|
|
227
|
+
ChocolateIngredients,
|
|
228
|
+
# 1. milk and sugar_grams are fully dynamically generated
|
|
229
|
+
# 2. a partially-customized Field inheriting its Field type, default, etc from the attrs class definition
|
|
230
|
+
cacao_src=atacama.neo.field(
|
|
231
|
+
validate=ma.validate.OneOf(["Ivory Coast", "Nigeria", "Ghana", "Cameroon"])
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class MallowSchema(ma.Schema):
|
|
237
|
+
"""Why are you doing this by hand?"""
|
|
238
|
+
|
|
239
|
+
gooeyness = EnumField(GooeyEnum, by_value=True)
|
|
240
|
+
color = ma.fields.Raw()
|
|
241
|
+
|
|
242
|
+
@ma.post_load
|
|
243
|
+
def pl(self, data: dict, **_kw):
|
|
244
|
+
return Mallow(**data)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
SmoreFromApiSchema = atacama.ordered(
|
|
248
|
+
Smore,
|
|
249
|
+
# 1. graham_cracker, by being omitted, will have a nested schema generated with no customizations
|
|
250
|
+
# 5. In order to name/path the fields of nested elements, we plug in a nested
|
|
251
|
+
# SchemaGenerator.
|
|
252
|
+
#
|
|
253
|
+
# Note that keyword arguments applicable to the Field surrounding the nested Schema,
|
|
254
|
+
# e.g. load_only, are supplied to the `nested` method, whereas 'paths' to attributes within the nested class
|
|
255
|
+
# are supplied to the returned NestedSchemaGenerator function.
|
|
256
|
+
#
|
|
257
|
+
# Note also that we use a different SchemaGenerator (neo) than the parent (ordered),
|
|
258
|
+
# and this is perfectly fine and works as you'd expect.
|
|
259
|
+
chocolate=atacama.neo.nested(load_only=True)(
|
|
260
|
+
# 2. Both pct_cacao and brand have customizations but are otherwise dynamically generated.
|
|
261
|
+
# Note in particular that we do not need to specify the `attrs` class itself, as that
|
|
262
|
+
# is known from the type of the `chocolate` attribute.
|
|
263
|
+
cacao_pct=atacama.neo.field(validate=ma.validate.Range(min=0, max=100)),
|
|
264
|
+
brand=atacama.neo.field(validate=ma.validate.OneOf(["nestle", "hershey"])),
|
|
265
|
+
# 4. we reuse the previously defined ChocolateIngredientsFromApi Schema
|
|
266
|
+
ingredients=ChocolateIngredientsFromApiSchema,
|
|
267
|
+
),
|
|
268
|
+
# 3. Here, the list of Mallows is represented by a statically defined NestedField
|
|
269
|
+
# containing a statically defined Schema.
|
|
270
|
+
# Why? Who knows, but if you want to do it yourself, it's possible!
|
|
271
|
+
marshmallows=ma.fields.Nested(MallowSchema(many=True)),
|
|
272
|
+
)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## How
|
|
276
|
+
|
|
277
|
+
### SchemaGenerators
|
|
278
|
+
|
|
279
|
+
All interaction with `atacama` is done via a top-level `SchemaGenerator` object. It contains some
|
|
280
|
+
contextual information which will be reused recursively throughout a generated `Schema`, including a way
|
|
281
|
+
to define the `Meta` class that is a core part of Marshmallow's configurability.
|
|
282
|
+
|
|
283
|
+
`atacama` currently provides two 'default' schema generators, `neo` and `ordered`.
|
|
284
|
+
|
|
285
|
+
- `ordered` provides no configuration other than the common specification that the generated Schema
|
|
286
|
+
should preserve the order of the attributes as they appear in the class - while this may not matter for
|
|
287
|
+
most runtime use cases, it is infinitely valuable for debuggability and for further ecosystem usage
|
|
288
|
+
such as OpenAPI spec generation, which ought to follow the order defined by the `attrs` class.
|
|
289
|
+
|
|
290
|
+
- `neo` stands for "non-empty, ordered", and is the preferred generator for new Schemas, because it
|
|
291
|
+
builds in a very opinionated but nonetheless generally useful concept of non-emptiness. For attributes
|
|
292
|
+
of types that properly have lengths, it is in general the case that one and only one of the following
|
|
293
|
+
should be true:
|
|
294
|
+
|
|
295
|
+
1. Your attribute has a default defined, such that it is not required to be present in input data for
|
|
296
|
+
successful deserialization.
|
|
297
|
+
1. It is illegal to provide an empty, zero-length value.
|
|
298
|
+
|
|
299
|
+
The intuition here is that a given attribute type either _may_ have an 'essentially empty' value, or it
|
|
300
|
+
may not. Examples of things which may never be empty include database ids (empty string would be
|
|
301
|
+
inappropriate), lists of object 'owners' (an empty list would orphan the object, and therefore must not
|
|
302
|
+
be permitted), etc. Whereas in many cases, an empty string or list is perfectly normal, and in those
|
|
303
|
+
cases it is preferred that the class itself define the common-sense default value in order to make
|
|
304
|
+
things work as expected without boilerplate.
|
|
305
|
+
|
|
306
|
+
### FieldTransforms
|
|
307
|
+
|
|
308
|
+
The `neo` `SchemaGenerator` performs the additional 'non-empty' validation to non-defaulted Fields via
|
|
309
|
+
something called a `FieldTransform`. Any `FieldTransform` attached to a `SchemaGenerator` will be run on
|
|
310
|
+
_every_ `Field` attached to the Schema, _recursively_. This includes statically-provided Fields.
|
|
311
|
+
|
|
312
|
+
The `FieldTransform` must accept an actual `Field` object and returns a (presumably modified) `Field`
|
|
313
|
+
object. This is only run at the time of `Schema` generation, so if you wish to add validators or perform
|
|
314
|
+
customization to the Field that happens at load/dump time, you must compose your logic with the existing
|
|
315
|
+
`Field`. A Schema generator can have multiple FieldTransforms, and they will be run _in order_ on every
|
|
316
|
+
`Field`. A `FieldTransform` is, in essence, a higher-order function over `Field`, which are themselves
|
|
317
|
+
functions for the incoming attribute data.
|
|
318
|
+
|
|
319
|
+
The two default generators are provided as a convenience to the user and nothing more - it is perfectly
|
|
320
|
+
acceptable and indeed expected that you might define your own 'sorts' of schema generators, with your own
|
|
321
|
+
`FieldTransforms` and basic `Meta` definitions, depending on your needs.
|
|
322
|
+
|
|
323
|
+
### Leaf type->Field mapping
|
|
324
|
+
|
|
325
|
+
As a recursive generator, there must be known base cases where a concrete Marshmallow `Field` can be
|
|
326
|
+
automatically generated based on the type of an attribute.
|
|
327
|
+
|
|
328
|
+
#### Built-in mappings
|
|
329
|
+
|
|
330
|
+
The default base cases are defined in `atacama/leaf.py`. They are relatively comprehensive as far as
|
|
331
|
+
Python builtins go, covering various date/time concepts and UUID. We also specifically map
|
|
332
|
+
`Union[int, float]` to the Marshmallow `Number` `Field`. Further, we support `typing_extensions.Literal`
|
|
333
|
+
using the built-in Marshmallow validator `OneOf`, and we have introduced a simple `Set` `Field` that
|
|
334
|
+
serializes `set`s to sorted `list`s.
|
|
335
|
+
|
|
336
|
+
#### Custom static mappings
|
|
337
|
+
|
|
338
|
+
Nevertheless, you may find that you wish to configure a more comprehensive (or different) set of leaf
|
|
339
|
+
types for your `SchemaGenerator`. This may be configured by passing the keyword argument `leaf_types` to
|
|
340
|
+
the `SchemaGenerator` constructor with a mapping of those leaf types. A `dict` is sufficient to provide a
|
|
341
|
+
static `LeafTypeMapping`.
|
|
342
|
+
|
|
343
|
+
#### Custom dynamic mappings
|
|
344
|
+
|
|
345
|
+
You may also provide a more dynamic implementation of the `Protocol` defined in `atacama/leaf.py`. This
|
|
346
|
+
would provide functionality similar to `cattrs.register_structure_hook`, except that a Marshmallow
|
|
347
|
+
`Field` handles both serialization and deserialization. The included `DynamicLeafTypeMapping` class can
|
|
348
|
+
help accomplish this, though you may provide your own custom implementation of the Protocol as well.
|
|
349
|
+
`DynamicLeafTypeMapping` is recursively nestable, so you may overlay your own handlers on top of our base
|
|
350
|
+
handlers via:
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
from atacama import DynamicLeafTypeMapping, AtacamaBaseLeafTypeMapping
|
|
354
|
+
|
|
355
|
+
your_mapping = DynamicLeafTypeMapping(AtacamaBaseLeafTypeMapping, [handler_1, handler_2])
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Minor Features
|
|
359
|
+
|
|
360
|
+
### `require_all`
|
|
361
|
+
|
|
362
|
+
You may specify at generation time that you wish to make all fields (recursively) `required` at the time
|
|
363
|
+
of load. This may be useful on its own, but is also the only way of accurately describing an 'output'
|
|
364
|
+
type in a JSON/OpenAPI schema, because `required` in that context is the only way to indicate that your
|
|
365
|
+
attribute will never be `undefined`. When dumping an `attrs` class to Python dictionary, all attributes
|
|
366
|
+
are always guaranteed to be present in the output, so `undefined` will never happen even for attributes
|
|
367
|
+
with defaults.
|
|
368
|
+
|
|
369
|
+
Example:
|
|
370
|
+
|
|
371
|
+
`atacama.neo(Foo, config(require_all=True))`
|
|
372
|
+
|
|
373
|
+
### Schema name suffix
|
|
374
|
+
|
|
375
|
+
You may specify a suffix for the name of the Schema generated. This may be useful when you are trying to
|
|
376
|
+
generate an output JSON schema and have multiple Schemas derived from the same `attrs` class.
|
|
377
|
+
|
|
378
|
+
Example:
|
|
379
|
+
|
|
380
|
+
`atacama.neo(Foo, config(schema_name_suffix='Input'))` results in the schema having the name
|
|
381
|
+
`your_module.FooInput` rather than `your_module.Foo`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
thds/atacama/__init__.py,sha256=GYDLPjg7E91sPlOKZ8UyhSDCRvhUSbg9hbL-fK2hzZY,265
|
|
2
|
+
thds/atacama/_attrs.py,sha256=3g3mXdUJRsaKgGnzDWZ8AoqM8pgLL9GMeY1cF7-sN6o,1556
|
|
3
|
+
thds/atacama/_cache.py,sha256=fYZ6iYKvQClCRtuGIQAMdJHyVHJ9Ffyr0dW1eyaMnwA,1477
|
|
4
|
+
thds/atacama/_config.py,sha256=Z2gqxnBJvk9plS-3vgNchfq3Z7wBsuyu0SqAmnKFg2o,1442
|
|
5
|
+
thds/atacama/_generic_dispatch.py,sha256=N5gZGTLMDVCwbf3OAx1EIA-wG3_T6CICiy65D-A_RKI,2256
|
|
6
|
+
thds/atacama/_meta.py,sha256=sOjP0Y0KiNZUAXrxg2JWwolSWlxgU_OUqniUR_2Iv8g,498
|
|
7
|
+
thds/atacama/custom_fields.py,sha256=leIsn40R5TexbAgTj1X-HUtkopfEMp5Hwb-0PYQGQ2U,945
|
|
8
|
+
thds/atacama/field_transforms.py,sha256=9Y1HO0U7oZZhmpYegTM1ruZX0hZMgdWS6b4gARb_Yo8,528
|
|
9
|
+
thds/atacama/fields.py,sha256=zIfFCyUO1YcbKiLsl3OX-xhx3JxnyRBkP6kf-nOezsA,5281
|
|
10
|
+
thds/atacama/generators.py,sha256=O6LvFaFpsfvAD4l_cCa82usF1RQ4aBHhv8bLikJ5rL0,486
|
|
11
|
+
thds/atacama/leaf.py,sha256=C7HDGBS_Evds35qWeBLQAw1ZahERJONh-WrguZzIMvs,4575
|
|
12
|
+
thds/atacama/meta.json,sha256=Ng3G3l0SnGsdEsovlwUcoJAW2DOqN_Ncqn5_4CP6zAU,195
|
|
13
|
+
thds/atacama/nonempty.py,sha256=bL1cqNpUzATosiqGdEPEn6zlYcliySKH0tj__2UnijU,1384
|
|
14
|
+
thds/atacama/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
thds/atacama/schemas.py,sha256=CqSaj1KJGPuXzHkjdSVrZUhhd3dagmEUtYuBEiJGJ_g,9418
|
|
16
|
+
thds.atacama-1.0.20250123022552.dist-info/METADATA,sha256=__aN0JIk5fIE29jvMzPJak9lf7pS1LTj7BaRdJqmUfc,17643
|
|
17
|
+
thds.atacama-1.0.20250123022552.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
18
|
+
thds.atacama-1.0.20250123022552.dist-info/top_level.txt,sha256=LTZaE5SkWJwv9bwOlMbIhiS-JWQEEIcjVYnJrt-CriY,5
|
|
19
|
+
thds.atacama-1.0.20250123022552.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
thds
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
thds.atacama-0.0.1.dist-info/METADATA,sha256=vQEhS2GDeMIOvQ_e1uUbtYRtZbd-p72F1J4CjZepjhg,231
|
|
2
|
-
thds.atacama-0.0.1.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92
|
|
3
|
-
thds.atacama-0.0.1.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
|
-
thds.atacama-0.0.1.dist-info/RECORD,,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|