thds.atacama 0.0.1__py3-none-any.whl → 1.0.20250116223906__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.
@@ -0,0 +1,5 @@
1
+ from ._config import config # noqa: F401
2
+ from ._meta import meta # noqa: F401
3
+ from .generators import neo, ordered # noqa: F401
4
+ from .leaf import AtacamaBaseLeafTypeMapping, DynamicLeafTypeMapping # noqa: F401
5
+ from .schemas import SchemaGenerator # noqa: F401
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
@@ -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
@@ -0,0 +1,8 @@
1
+ {
2
+ "git_commit": "8119ce98e26d99335cda51ac5cbebbcd6d87c416",
3
+ "git_branch": "task/ci/open-source",
4
+ "git_is_clean": true,
5
+ "pyproject_version": "1.0.20250116223906",
6
+ "thds_user": "peter.gaultney",
7
+ "misc": {}
8
+ }
@@ -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
@@ -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.20250116223906
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=jMZ3raSnAr43-2bK4AY3WjPwpgzJKnYo1akjeRUrXjM,218
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.20250116223906.dist-info/METADATA,sha256=U8WsIGeLpwYCz8KTARji_cGX57XAkX65EAtbfvRUXi0,17643
17
+ thds.atacama-1.0.20250116223906.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
18
+ thds.atacama-1.0.20250116223906.dist-info/top_level.txt,sha256=LTZaE5SkWJwv9bwOlMbIhiS-JWQEEIcjVYnJrt-CriY,5
19
+ thds.atacama-1.0.20250116223906.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.41.2)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,8 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: thds.atacama
3
- Version: 0.0.1
4
- Home-page: https://www.trillianthealth.com
5
- Author: Trilliant Health Data Science
6
- Author-email: datascience@trillianthealth.com
7
- Classifier: Development Status :: 1 - Planning
8
-
@@ -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
-