Skip to content

Commit 8fe5373

Browse files
authored
Fix unstructuring literals with enums (#598)
* Fix unstructuring literals with enums * preconf: faster enum handling * Optimize literals with enums
1 parent 3126f3f commit 8fe5373

15 files changed

+387
-35
lines changed

HISTORY.md

+5
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
2222
- Some `defaultdicts` are now [supported by default](https://catt.rs/en/latest/defaulthooks.html#defaultdicts), and
2323
{func}`cattrs.cols.is_defaultdict`{func} and `cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`.
2424
([#519](https://github.com/python-attrs/cattrs/issues/519) [#588](https://github.com/python-attrs/cattrs/pull/588))
25+
- Many preconf converters (_bson_, stdlib JSON, _cbor2_, _msgpack_, _msgspec_, _orjson_, _ujson_) skip unstructuring `int` and `str` enums,
26+
leaving them to the underlying libraries to handle with greater efficiency.
27+
([#598](https://github.com/python-attrs/cattrs/pull/598))
28+
- Literals containing enums are now unstructured properly, and their unstructuring is greatly optimized in the _bson_, stdlib JSON, _cbor2_, _msgpack_, _msgspec_, _orjson_ and _ujson_ preconf converters.
29+
([#598](https://github.com/python-attrs/cattrs/pull/598))
2530
- Replace `cattrs.gen.MappingStructureFn` with `cattrs.SimpleStructureHook[In, T]`.
2631
- Python 3.13 is now supported.
2732
([#543](https://github.com/python-attrs/cattrs/pull/543) [#547](https://github.com/python-attrs/cattrs/issues/547))

docs/preconf.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,26 @@
22

33
The {mod}`cattrs.preconf` package contains factories for preconfigured converters, specifically adjusted for particular serialization libraries.
44

5-
For example, to get a converter configured for BSON:
5+
For example, to get a converter configured for _orjson_:
66

77
```{doctest}
88
9-
>>> from cattrs.preconf.bson import make_converter
9+
>>> from cattrs.preconf.orjson import make_converter
1010
1111
>>> converter = make_converter() # Takes the same parameters as the `cattrs.Converter`
1212
```
1313

1414
Converters obtained this way can be customized further, just like any other converter.
1515

16+
For compatibility and performance reasons, these converters are usually configured to unstructure differently than ordinary `Converters`.
17+
A couple of examples:
18+
* the {class}`_orjson_ converter <cattrs.preconf.orjson.OrjsonConverter>` is configured to pass `datetime` instances unstructured since _orjson_ can handle them faster.
19+
* the {class}`_msgspec_ JSON converter <cattrs.preconf.msgspec.MsgspecJsonConverter>` is configured to pass through some dataclasses and _attrs_classes,
20+
if the output is identical to what normal unstructuring would have produced, since _msgspec_ can handle them faster.
21+
22+
The intended usage is to pass the unstructured output directly to the underlying library,
23+
or use `converter.dumps` which will do it for you.
24+
1625
These converters support all [default hooks](defaulthooks.md)
1726
and the following additional classes and type annotations,
1827
both for structuring and unstructuring:

src/cattrs/_compat.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,8 @@ def get_final_base(type) -> Optional[type]:
236236
# Not present on 3.9.0, so we try carefully.
237237
from typing import _LiteralGenericAlias
238238

239-
def is_literal(type) -> bool:
239+
def is_literal(type: Any) -> bool:
240+
"""Is this a literal?"""
240241
return type in LITERALS or (
241242
isinstance(
242243
type, (_GenericAlias, _LiteralGenericAlias, _SpecialGenericAlias)

src/cattrs/converters.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
)
9292
from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn
9393
from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn
94+
from .literals import is_literal_containing_enums
9495
from .types import SimpleStructureHook
9596

9697
__all__ = ["UnstructureStrategy", "BaseConverter", "Converter", "GenConverter"]
@@ -146,10 +147,6 @@ class UnstructureStrategy(Enum):
146147
AS_TUPLE = "astuple"
147148

148149

149-
def is_literal_containing_enums(typ: type) -> bool:
150-
return is_literal(typ) and any(isinstance(val, Enum) for val in typ.__args__)
151-
152-
153150
def _is_extended_factory(factory: Callable) -> bool:
154151
"""Does this factory also accept a converter arg?"""
155152
# We use the original `inspect.signature` to not evaluate string
@@ -238,6 +235,7 @@ def __init__(
238235
lambda t: self.get_unstructure_hook(get_type_alias_base(t)),
239236
True,
240237
),
238+
(is_literal_containing_enums, self.unstructure),
241239
(is_mapping, self._unstructure_mapping),
242240
(is_sequence, self._unstructure_seq),
243241
(is_mutable_set, self._unstructure_seq),

src/cattrs/literals.py

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from enum import Enum
2+
from typing import Any
3+
4+
from ._compat import is_literal
5+
6+
__all__ = ["is_literal", "is_literal_containing_enums"]
7+
8+
9+
def is_literal_containing_enums(type: Any) -> bool:
10+
"""Is this a literal containing at least one Enum?"""
11+
return is_literal(type) and any(isinstance(val, Enum) for val in type.__args__)

src/cattrs/preconf/__init__.py

+29-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import sys
22
from datetime import datetime
3-
from typing import Any, Callable, TypeVar
3+
from enum import Enum
4+
from typing import Any, Callable, TypeVar, get_args
5+
6+
from .._compat import is_subclass
7+
from ..converters import Converter, UnstructureHook
8+
from ..fns import identity
49

510
if sys.version_info[:2] < (3, 10):
611
from typing_extensions import ParamSpec
@@ -25,3 +30,26 @@ def impl(x: Callable[..., T]) -> Callable[P, T]:
2530
return x
2631

2732
return impl
33+
34+
35+
def is_primitive_enum(type: Any, include_bare_enums: bool = False) -> bool:
36+
"""Is this a string or int enum that can be passed through?"""
37+
return is_subclass(type, Enum) and (
38+
is_subclass(type, (str, int))
39+
or (include_bare_enums and type.mro()[1:] == Enum.mro())
40+
)
41+
42+
43+
def literals_with_enums_unstructure_factory(
44+
typ: Any, converter: Converter
45+
) -> UnstructureHook:
46+
"""An unstructure hook factory for literals containing enums.
47+
48+
If all contained enums can be passed through (their unstructure hook is `identity`),
49+
the entire literal can also be passed through.
50+
"""
51+
if all(
52+
converter.get_unstructure_hook(type(arg)) == identity for arg in get_args(typ)
53+
):
54+
return identity
55+
return converter.unstructure

src/cattrs/preconf/bson.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@
1111

1212
from ..converters import BaseConverter, Converter
1313
from ..dispatch import StructureHook
14+
from ..fns import identity
15+
from ..literals import is_literal_containing_enums
1416
from ..strategies import configure_union_passthrough
15-
from . import validate_datetime, wrap
17+
from . import (
18+
is_primitive_enum,
19+
literals_with_enums_unstructure_factory,
20+
validate_datetime,
21+
wrap,
22+
)
1623

1724
T = TypeVar("T")
1825

@@ -52,6 +59,10 @@ def configure_converter(converter: BaseConverter):
5259
* byte mapping keys are base85-encoded into strings when unstructuring, and reverse
5360
* non-string, non-byte mapping keys are coerced into strings when unstructuring
5461
* a deserialization hook is registered for bson.ObjectId by default
62+
* string and int enums are passed through when unstructuring
63+
64+
.. versionchanged: 24.2.0
65+
Enums are left to the library to unstructure, speeding them up.
5566
"""
5667

5768
def gen_unstructure_mapping(cl: Any, unstructure_to=None):
@@ -92,6 +103,10 @@ def gen_structure_mapping(cl: Any) -> StructureHook:
92103
converter.register_structure_hook(datetime, validate_datetime)
93104
converter.register_unstructure_hook(date, lambda v: v.isoformat())
94105
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
106+
converter.register_unstructure_hook_func(is_primitive_enum, identity)
107+
converter.register_unstructure_hook_factory(
108+
is_literal_containing_enums, literals_with_enums_unstructure_factory
109+
)
95110

96111

97112
@wrap(BsonConverter)

src/cattrs/preconf/cbor2.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from cattrs._compat import AbstractSet
99

1010
from ..converters import BaseConverter, Converter
11+
from ..fns import identity
12+
from ..literals import is_literal_containing_enums
1113
from ..strategies import configure_union_passthrough
12-
from . import wrap
14+
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap
1315

1416
T = TypeVar("T")
1517

@@ -28,13 +30,18 @@ def configure_converter(converter: BaseConverter):
2830
2931
* datetimes are serialized as timestamp floats
3032
* sets are serialized as lists
33+
* string and int enums are passed through when unstructuring
3134
"""
3235
converter.register_unstructure_hook(datetime, lambda v: v.timestamp())
3336
converter.register_structure_hook(
3437
datetime, lambda v, _: datetime.fromtimestamp(v, timezone.utc)
3538
)
3639
converter.register_unstructure_hook(date, lambda v: v.isoformat())
3740
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
41+
converter.register_unstructure_hook_func(is_primitive_enum, identity)
42+
converter.register_unstructure_hook_factory(
43+
is_literal_containing_enums, literals_with_enums_unstructure_factory
44+
)
3845
configure_union_passthrough(Union[str, bool, int, float, None, bytes], converter)
3946

4047

src/cattrs/preconf/json.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
from .._compat import AbstractSet, Counter
99
from ..converters import BaseConverter, Converter
10+
from ..fns import identity
11+
from ..literals import is_literal_containing_enums
1012
from ..strategies import configure_union_passthrough
11-
from . import wrap
13+
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap
1214

1315
T = TypeVar("T")
1416

@@ -29,8 +31,12 @@ def configure_converter(converter: BaseConverter):
2931
* datetimes are serialized as ISO 8601
3032
* counters are serialized as dicts
3133
* sets are serialized as lists
34+
* string and int enums are passed through when unstructuring
3235
* union passthrough is configured for unions of strings, bools, ints,
3336
floats and None
37+
38+
.. versionchanged: 24.2.0
39+
Enums are left to the library to unstructure, speeding them up.
3440
"""
3541
converter.register_unstructure_hook(
3642
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
@@ -40,6 +46,10 @@ def configure_converter(converter: BaseConverter):
4046
converter.register_structure_hook(datetime, lambda v, _: datetime.fromisoformat(v))
4147
converter.register_unstructure_hook(date, lambda v: v.isoformat())
4248
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
49+
converter.register_unstructure_hook_factory(
50+
is_literal_containing_enums, literals_with_enums_unstructure_factory
51+
)
52+
converter.register_unstructure_hook_func(is_primitive_enum, identity)
4353
configure_union_passthrough(Union[str, bool, int, float, None], converter)
4454

4555

src/cattrs/preconf/msgpack.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from cattrs._compat import AbstractSet
99

1010
from ..converters import BaseConverter, Converter
11+
from ..fns import identity
12+
from ..literals import is_literal_containing_enums
1113
from ..strategies import configure_union_passthrough
12-
from . import wrap
14+
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap
1315

1416
T = TypeVar("T")
1517

@@ -28,6 +30,10 @@ def configure_converter(converter: BaseConverter):
2830
2931
* datetimes are serialized as timestamp floats
3032
* sets are serialized as lists
33+
* string and int enums are passed through when unstructuring
34+
35+
.. versionchanged: 24.2.0
36+
Enums are left to the library to unstructure, speeding them up.
3137
"""
3238
converter.register_unstructure_hook(datetime, lambda v: v.timestamp())
3339
converter.register_structure_hook(
@@ -39,6 +45,10 @@ def configure_converter(converter: BaseConverter):
3945
converter.register_structure_hook(
4046
date, lambda v, _: datetime.fromtimestamp(v, timezone.utc).date()
4147
)
48+
converter.register_unstructure_hook_func(is_primitive_enum, identity)
49+
converter.register_unstructure_hook_factory(
50+
is_literal_containing_enums, literals_with_enums_unstructure_factory
51+
)
4252
configure_union_passthrough(Union[str, bool, int, float, None, bytes], converter)
4353

4454

src/cattrs/preconf/msgspec.py

+14-4
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@
2727
from ..dispatch import UnstructureHook
2828
from ..fns import identity
2929
from ..gen import make_hetero_tuple_unstructure_fn
30+
from ..literals import is_literal_containing_enums
3031
from ..strategies import configure_union_passthrough
31-
from . import wrap
32+
from . import literals_with_enums_unstructure_factory, wrap
3233

3334
T = TypeVar("T")
3435

@@ -72,16 +73,23 @@ def configure_converter(converter: Converter) -> None:
7273
* datetimes and dates are passed through to be serialized as RFC 3339 directly
7374
* enums are passed through to msgspec directly
7475
* union passthrough configured for str, bool, int, float and None
76+
* bare, string and int enums are passed through when unstructuring
77+
78+
.. versionchanged: 24.2.0
79+
Enums are left to the library to unstructure, speeding them up.
7580
"""
7681
configure_passthroughs(converter)
7782

7883
converter.register_unstructure_hook(Struct, to_builtins)
79-
converter.register_unstructure_hook(Enum, to_builtins)
84+
converter.register_unstructure_hook(Enum, identity)
8085

8186
converter.register_structure_hook(Struct, convert)
8287
converter.register_structure_hook(bytes, lambda v, _: b64decode(v))
8388
converter.register_structure_hook(datetime, lambda v, _: convert(v, datetime))
8489
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
90+
converter.register_unstructure_hook_factory(
91+
is_literal_containing_enums, literals_with_enums_unstructure_factory
92+
)
8593
configure_union_passthrough(Union[str, bool, int, float, None], converter)
8694

8795

@@ -100,7 +108,7 @@ def configure_passthroughs(converter: Converter) -> None:
100108
converter.register_unstructure_hook(bytes, to_builtins)
101109
converter.register_unstructure_hook_factory(is_mapping, mapping_unstructure_factory)
102110
converter.register_unstructure_hook_factory(is_sequence, seq_unstructure_factory)
103-
converter.register_unstructure_hook_factory(has, attrs_unstructure_factory)
111+
converter.register_unstructure_hook_factory(has, msgspec_attrs_unstructure_factory)
104112
converter.register_unstructure_hook_factory(
105113
is_namedtuple, namedtuple_unstructure_factory
106114
)
@@ -145,7 +153,9 @@ def mapping_unstructure_factory(type, converter: BaseConverter) -> UnstructureHo
145153
return converter.gen_unstructure_mapping(type)
146154

147155

148-
def attrs_unstructure_factory(type: Any, converter: Converter) -> UnstructureHook:
156+
def msgspec_attrs_unstructure_factory(
157+
type: Any, converter: Converter
158+
) -> UnstructureHook:
149159
"""Choose whether to use msgspec handling or our own."""
150160
origin = get_origin(type)
151161
attribs = fields(origin or type)

src/cattrs/preconf/orjson.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
from ..cols import is_namedtuple, namedtuple_unstructure_factory
1313
from ..converters import BaseConverter, Converter
1414
from ..fns import identity
15+
from ..literals import is_literal_containing_enums
1516
from ..strategies import configure_union_passthrough
16-
from . import wrap
17+
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap
1718

1819
T = TypeVar("T")
1920

@@ -36,9 +37,12 @@ def configure_converter(converter: BaseConverter):
3637
* sets are serialized as lists
3738
* string enum mapping keys have special handling
3839
* mapping keys are coerced into strings when unstructuring
40+
* bare, string and int enums are passed through when unstructuring
3941
4042
.. versionchanged: 24.1.0
4143
Add support for typed namedtuples.
44+
.. versionchanged: 24.2.0
45+
Enums are left to the library to unstructure, speeding them up.
4246
"""
4347
converter.register_unstructure_hook(
4448
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
@@ -80,6 +84,12 @@ def key_handler(v):
8084
),
8185
]
8286
)
87+
converter.register_unstructure_hook_func(
88+
partial(is_primitive_enum, include_bare_enums=True), identity
89+
)
90+
converter.register_unstructure_hook_factory(
91+
is_literal_containing_enums, literals_with_enums_unstructure_factory
92+
)
8393
configure_union_passthrough(Union[str, bool, int, float, None], converter)
8494

8595

0 commit comments

Comments
 (0)