Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
- Some `defaultdicts` are now [supported by default](https://catt.rs/en/latest/defaulthooks.html#defaultdicts), and
{func}`cattrs.cols.is_defaultdict`{func} and `cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`.
([#519](https://github.com/python-attrs/cattrs/issues/519) [#588](https://github.com/python-attrs/cattrs/pull/588))
- Many preconf converters (_bson_, stdlib JSON, _cbor2_, _msgpack_, _msgspec_, _orjson_, _ujson_) skip unstructuring `int` and `str` enums,
leaving them to the underlying libraries to handle with greater efficiency.
([#598](https://github.com/python-attrs/cattrs/pull/598))
- 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.
([#598](https://github.com/python-attrs/cattrs/pull/598))
- Replace `cattrs.gen.MappingStructureFn` with `cattrs.SimpleStructureHook[In, T]`.
- Python 3.13 is now supported.
([#543](https://github.com/python-attrs/cattrs/pull/543) [#547](https://github.com/python-attrs/cattrs/issues/547))
Expand Down
13 changes: 11 additions & 2 deletions docs/preconf.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,26 @@

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

For example, to get a converter configured for BSON:
For example, to get a converter configured for _orjson_:

```{doctest}

>>> from cattrs.preconf.bson import make_converter
>>> from cattrs.preconf.orjson import make_converter

>>> converter = make_converter() # Takes the same parameters as the `cattrs.Converter`
```

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

For compatibility and performance reasons, these converters are usually configured to unstructure differently than ordinary `Converters`.
A couple of examples:
* the {class}`_orjson_ converter <cattrs.preconf.orjson.OrjsonConverter>` is configured to pass `datetime` instances unstructured since _orjson_ can handle them faster.
* the {class}`_msgspec_ JSON converter <cattrs.preconf.msgspec.MsgspecJsonConverter>` is configured to pass through some dataclasses and _attrs_classes,
if the output is identical to what normal unstructuring would have produced, since _msgspec_ can handle them faster.

The intended usage is to pass the unstructured output directly to the underlying library,
or use `converter.dumps` which will do it for you.

These converters support all [default hooks](defaulthooks.md)
and the following additional classes and type annotations,
both for structuring and unstructuring:
Expand Down
3 changes: 2 additions & 1 deletion src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ def get_final_base(type) -> Optional[type]:
# Not present on 3.9.0, so we try carefully.
from typing import _LiteralGenericAlias

def is_literal(type) -> bool:
def is_literal(type: Any) -> bool:
"""Is this a literal?"""
return type in LITERALS or (
isinstance(
type, (_GenericAlias, _LiteralGenericAlias, _SpecialGenericAlias)
Expand Down
6 changes: 2 additions & 4 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
)
from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn
from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn
from .literals import is_literal_containing_enums
from .types import SimpleStructureHook

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


def is_literal_containing_enums(typ: type) -> bool:
return is_literal(typ) and any(isinstance(val, Enum) for val in typ.__args__)


def _is_extended_factory(factory: Callable) -> bool:
"""Does this factory also accept a converter arg?"""
# We use the original `inspect.signature` to not evaluate string
Expand Down Expand Up @@ -238,6 +235,7 @@ def __init__(
lambda t: self.get_unstructure_hook(get_type_alias_base(t)),
True,
),
(is_literal_containing_enums, self.unstructure),
(is_mapping, self._unstructure_mapping),
(is_sequence, self._unstructure_seq),
(is_mutable_set, self._unstructure_seq),
Expand Down
11 changes: 11 additions & 0 deletions src/cattrs/literals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from enum import Enum
from typing import Any

from ._compat import is_literal

__all__ = ["is_literal", "is_literal_containing_enums"]


def is_literal_containing_enums(type: Any) -> bool:
"""Is this a literal containing at least one Enum?"""
return is_literal(type) and any(isinstance(val, Enum) for val in type.__args__)
30 changes: 29 additions & 1 deletion src/cattrs/preconf/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import sys
from datetime import datetime
from typing import Any, Callable, TypeVar
from enum import Enum
from typing import Any, Callable, TypeVar, get_args

from .._compat import is_subclass
from ..converters import Converter, UnstructureHook
from ..fns import identity

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

return impl


def is_primitive_enum(type: Any, include_bare_enums: bool = False) -> bool:
"""Is this a string or int enum that can be passed through?"""
return is_subclass(type, Enum) and (
is_subclass(type, (str, int))
or (include_bare_enums and type.mro()[1:] == Enum.mro())
)


def literals_with_enums_unstructure_factory(
typ: Any, converter: Converter
) -> UnstructureHook:
"""An unstructure hook factory for literals containing enums.

If all contained enums can be passed through (their unstructure hook is `identity`),
the entire literal can also be passed through.
"""
if all(
converter.get_unstructure_hook(type(arg)) == identity for arg in get_args(typ)
):
return identity
return converter.unstructure
17 changes: 16 additions & 1 deletion src/cattrs/preconf/bson.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@

from ..converters import BaseConverter, Converter
from ..dispatch import StructureHook
from ..fns import identity
from ..literals import is_literal_containing_enums
from ..strategies import configure_union_passthrough
from . import validate_datetime, wrap
from . import (
is_primitive_enum,
literals_with_enums_unstructure_factory,
validate_datetime,
wrap,
)

T = TypeVar("T")

Expand Down Expand Up @@ -52,6 +59,10 @@ def configure_converter(converter: BaseConverter):
* byte mapping keys are base85-encoded into strings when unstructuring, and reverse
* non-string, non-byte mapping keys are coerced into strings when unstructuring
* a deserialization hook is registered for bson.ObjectId by default
* string and int enums are passed through when unstructuring

.. versionchanged: 24.2.0
Enums are left to the library to unstructure, speeding them up.
"""

def gen_unstructure_mapping(cl: Any, unstructure_to=None):
Expand Down Expand Up @@ -92,6 +103,10 @@ def gen_structure_mapping(cl: Any) -> StructureHook:
converter.register_structure_hook(datetime, validate_datetime)
converter.register_unstructure_hook(date, lambda v: v.isoformat())
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
converter.register_unstructure_hook_func(is_primitive_enum, identity)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)


@wrap(BsonConverter)
Expand Down
9 changes: 8 additions & 1 deletion src/cattrs/preconf/cbor2.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from cattrs._compat import AbstractSet

from ..converters import BaseConverter, Converter
from ..fns import identity
from ..literals import is_literal_containing_enums
from ..strategies import configure_union_passthrough
from . import wrap
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap

T = TypeVar("T")

Expand All @@ -28,13 +30,18 @@ def configure_converter(converter: BaseConverter):

* datetimes are serialized as timestamp floats
* sets are serialized as lists
* string and int enums are passed through when unstructuring
"""
converter.register_unstructure_hook(datetime, lambda v: v.timestamp())
converter.register_structure_hook(
datetime, lambda v, _: datetime.fromtimestamp(v, timezone.utc)
)
converter.register_unstructure_hook(date, lambda v: v.isoformat())
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
converter.register_unstructure_hook_func(is_primitive_enum, identity)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
configure_union_passthrough(Union[str, bool, int, float, None, bytes], converter)


Expand Down
12 changes: 11 additions & 1 deletion src/cattrs/preconf/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@

from .._compat import AbstractSet, Counter
from ..converters import BaseConverter, Converter
from ..fns import identity
from ..literals import is_literal_containing_enums
from ..strategies import configure_union_passthrough
from . import wrap
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap

T = TypeVar("T")

Expand All @@ -29,8 +31,12 @@ def configure_converter(converter: BaseConverter):
* datetimes are serialized as ISO 8601
* counters are serialized as dicts
* sets are serialized as lists
* string and int enums are passed through when unstructuring
* union passthrough is configured for unions of strings, bools, ints,
floats and None

.. versionchanged: 24.2.0
Enums are left to the library to unstructure, speeding them up.
"""
converter.register_unstructure_hook(
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
Expand All @@ -40,6 +46,10 @@ def configure_converter(converter: BaseConverter):
converter.register_structure_hook(datetime, lambda v, _: datetime.fromisoformat(v))
converter.register_unstructure_hook(date, lambda v: v.isoformat())
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
converter.register_unstructure_hook_func(is_primitive_enum, identity)
configure_union_passthrough(Union[str, bool, int, float, None], converter)


Expand Down
12 changes: 11 additions & 1 deletion src/cattrs/preconf/msgpack.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from cattrs._compat import AbstractSet

from ..converters import BaseConverter, Converter
from ..fns import identity
from ..literals import is_literal_containing_enums
from ..strategies import configure_union_passthrough
from . import wrap
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap

T = TypeVar("T")

Expand All @@ -28,6 +30,10 @@ def configure_converter(converter: BaseConverter):

* datetimes are serialized as timestamp floats
* sets are serialized as lists
* string and int enums are passed through when unstructuring

.. versionchanged: 24.2.0
Enums are left to the library to unstructure, speeding them up.
"""
converter.register_unstructure_hook(datetime, lambda v: v.timestamp())
converter.register_structure_hook(
Expand All @@ -39,6 +45,10 @@ def configure_converter(converter: BaseConverter):
converter.register_structure_hook(
date, lambda v, _: datetime.fromtimestamp(v, timezone.utc).date()
)
converter.register_unstructure_hook_func(is_primitive_enum, identity)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
configure_union_passthrough(Union[str, bool, int, float, None, bytes], converter)


Expand Down
18 changes: 14 additions & 4 deletions src/cattrs/preconf/msgspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@
from ..dispatch import UnstructureHook
from ..fns import identity
from ..gen import make_hetero_tuple_unstructure_fn
from ..literals import is_literal_containing_enums
from ..strategies import configure_union_passthrough
from . import wrap
from . import literals_with_enums_unstructure_factory, wrap

T = TypeVar("T")

Expand Down Expand Up @@ -72,16 +73,23 @@ def configure_converter(converter: Converter) -> None:
* datetimes and dates are passed through to be serialized as RFC 3339 directly
* enums are passed through to msgspec directly
* union passthrough configured for str, bool, int, float and None
* bare, string and int enums are passed through when unstructuring

.. versionchanged: 24.2.0
Enums are left to the library to unstructure, speeding them up.
"""
configure_passthroughs(converter)

converter.register_unstructure_hook(Struct, to_builtins)
converter.register_unstructure_hook(Enum, to_builtins)
converter.register_unstructure_hook(Enum, identity)

converter.register_structure_hook(Struct, convert)
converter.register_structure_hook(bytes, lambda v, _: b64decode(v))
converter.register_structure_hook(datetime, lambda v, _: convert(v, datetime))
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
configure_union_passthrough(Union[str, bool, int, float, None], converter)


Expand All @@ -100,7 +108,7 @@ def configure_passthroughs(converter: Converter) -> None:
converter.register_unstructure_hook(bytes, to_builtins)
converter.register_unstructure_hook_factory(is_mapping, mapping_unstructure_factory)
converter.register_unstructure_hook_factory(is_sequence, seq_unstructure_factory)
converter.register_unstructure_hook_factory(has, attrs_unstructure_factory)
converter.register_unstructure_hook_factory(has, msgspec_attrs_unstructure_factory)
converter.register_unstructure_hook_factory(
is_namedtuple, namedtuple_unstructure_factory
)
Expand Down Expand Up @@ -145,7 +153,9 @@ def mapping_unstructure_factory(type, converter: BaseConverter) -> UnstructureHo
return converter.gen_unstructure_mapping(type)


def attrs_unstructure_factory(type: Any, converter: Converter) -> UnstructureHook:
def msgspec_attrs_unstructure_factory(
type: Any, converter: Converter
) -> UnstructureHook:
"""Choose whether to use msgspec handling or our own."""
origin = get_origin(type)
attribs = fields(origin or type)
Expand Down
12 changes: 11 additions & 1 deletion src/cattrs/preconf/orjson.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
from ..cols import is_namedtuple, namedtuple_unstructure_factory
from ..converters import BaseConverter, Converter
from ..fns import identity
from ..literals import is_literal_containing_enums
from ..strategies import configure_union_passthrough
from . import wrap
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap

T = TypeVar("T")

Expand All @@ -36,9 +37,12 @@ def configure_converter(converter: BaseConverter):
* sets are serialized as lists
* string enum mapping keys have special handling
* mapping keys are coerced into strings when unstructuring
* bare, string and int enums are passed through when unstructuring

.. versionchanged: 24.1.0
Add support for typed namedtuples.
.. versionchanged: 24.2.0
Enums are left to the library to unstructure, speeding them up.
"""
converter.register_unstructure_hook(
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
Expand Down Expand Up @@ -80,6 +84,12 @@ def key_handler(v):
),
]
)
converter.register_unstructure_hook_func(
partial(is_primitive_enum, include_bare_enums=True), identity
)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
configure_union_passthrough(Union[str, bool, int, float, None], converter)


Expand Down
Loading