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
3 changes: 2 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
([#707](https://github.com/python-attrs/cattrs/issues/707) [#708](https://github.com/python-attrs/cattrs/pull/708))
- The {mod}`tomlkit <cattrs.preconf.tomlkit>` preconf converter now passes date objects directly to _tomlkit_ for unstructuring.
([#707](https://github.com/python-attrs/cattrs/issues/707) [#708](https://github.com/python-attrs/cattrs/pull/708))

- Enum handling has been optimized by switching to hook factories, improving performance especially for plain enums.
([#705](https://github.com/python-attrs/cattrs/pull/705))

## 25.3.0 (2025-10-07)

Expand Down
23 changes: 5 additions & 18 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
is_mutable_set,
is_optional,
is_protocol,
is_subclass,
is_tuple,
is_typeddict,
is_union_type,
Expand Down Expand Up @@ -76,6 +77,7 @@
UnstructuredValue,
UnstructureHook,
)
from .enums import enum_structure_factory, enum_unstructure_factory
from .errors import (
IterableValidationError,
IterableValidationNote,
Expand Down Expand Up @@ -246,12 +248,12 @@ 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),
(is_frozenset, self._unstructure_seq),
(lambda t: issubclass(t, Enum), self._unstructure_enum),
(is_literal_containing_enums, self.unstructure),
(lambda t: is_subclass(t, Enum), enum_unstructure_factory, "extended"),
(has, self._unstructure_attrs),
(is_union_type, self._unstructure_union),
(lambda t: t in ANIES, self.unstructure),
Expand Down Expand Up @@ -298,6 +300,7 @@ def __init__(
self._union_struct_registry.__getitem__,
True,
),
(lambda t: is_subclass(t, Enum), enum_structure_factory, "extended"),
(has, self._structure_attrs),
]
)
Expand All @@ -308,7 +311,6 @@ def __init__(
(bytes, self._structure_call),
(int, self._structure_call),
(float, self._structure_call),
(Enum, self._structure_enum),
(Path, self._structure_call),
]
)
Expand Down Expand Up @@ -630,12 +632,6 @@ def unstructure_attrs_astuple(self, obj: Any) -> tuple[Any, ...]:
res.append(dispatch(a.type or v.__class__)(v))
return tuple(res)

def _unstructure_enum(self, obj: Enum) -> Any:
"""Convert an enum to its unstructured value."""
if "_value_" in obj.__class__.__annotations__:
return self._unstructure_func.dispatch(obj.value.__class__)(obj.value)
return obj.value

def _unstructure_seq(self, seq: Sequence[T]) -> Sequence[T]:
"""Convert a sequence to primitive equivalents."""
# We can reuse the sequence class, so tuples stay tuples.
Expand Down Expand Up @@ -715,15 +711,6 @@ def _structure_simple_literal(val, type):
raise Exception(f"{val} not in literal {type}")
return val

def _structure_enum(self, val: Any, cl: type[Enum]) -> Enum:
"""Structure ``val`` if possible and return the enum it corresponds to.

Uses type hints for the "_value_" attribute if they exist to structure
the enum values before returning the result."""
if "_value_" in cl.__annotations__:
val = self.structure(val, cl.__annotations__["_value_"])
return cl(val)

@staticmethod
def _structure_enum_literal(val, type):
vals = {(x.value if isinstance(x, Enum) else x): x for x in type.__args__}
Expand Down
36 changes: 36 additions & 0 deletions src/cattrs/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from collections.abc import Callable
from enum import Enum
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from .converters import BaseConverter


def enum_unstructure_factory(
type: type[Enum], converter: "BaseConverter"
) -> Callable[[Enum], Any]:
"""A factory for generating enum unstructure hooks.

If the enum is a typed enum (has `_value_`), we use the underlying value's hook.
Otherwise, we use the value directly.
"""
if "_value_" in type.__annotations__:
return lambda e: converter.unstructure(e.value)

return lambda e: e.value


def enum_structure_factory(
type: type[Enum], converter: "BaseConverter"
) -> Callable[[Any, type[Enum]], Enum]:
"""A factory for generating enum structure hooks.

If the enum is a typed enum (has `_value_`), we structure the value first.
Otherwise, we use the value directly.
"""
if "_value_" in type.__annotations__:
val_type = type.__annotations__["_value_"]
val_hook = converter.get_structure_hook(val_type)
return lambda v, _: type(val_hook(v, val_type))

return lambda v, _: type(v)
3 changes: 2 additions & 1 deletion src/cattrs/preconf/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from collections.abc import Callable
from datetime import datetime
from enum import Enum
from typing import Any, Callable, ParamSpec, TypeVar, get_args
from typing import Any, ParamSpec, TypeVar, get_args

from .._compat import is_subclass
from ..converters import Converter, UnstructureHook
Expand Down
4 changes: 2 additions & 2 deletions src/cattrs/preconf/bson.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ def gen_structure_mapping(cl: Any) -> StructureHook:

# datetime inherits from date, so identity unstructure hook used
# here to prevent the date unstructure hook running.
converter.register_unstructure_hook(datetime, lambda v: v)
converter.register_unstructure_hook(datetime, identity)
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_primitive_enum, lambda t: identity)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
Expand Down
2 changes: 1 addition & 1 deletion src/cattrs/preconf/cbor2.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def configure_converter(converter: BaseConverter):
)
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_primitive_enum, lambda t: identity)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
Expand Down
2 changes: 1 addition & 1 deletion src/cattrs/preconf/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def configure_converter(converter: BaseConverter) -> None:
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
converter.register_unstructure_hook_func(is_primitive_enum, identity)
converter.register_unstructure_hook_factory(is_primitive_enum, lambda _: identity)
configure_union_passthrough(Union[str, bool, int, float, None], converter)


Expand Down
2 changes: 1 addition & 1 deletion src/cattrs/preconf/msgpack.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def configure_converter(converter: BaseConverter) -> None:
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_primitive_enum, lambda t: identity)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
Expand Down
17 changes: 14 additions & 3 deletions src/cattrs/preconf/msgspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,27 @@
from __future__ import annotations

from base64 import b64decode
from collections.abc import Callable
from dataclasses import is_dataclass
from datetime import date, datetime
from enum import Enum
from functools import partial
from typing import Any, Callable, TypeVar, Union, get_type_hints
from typing import Any, TypeVar, Union, get_type_hints

from attrs import has as attrs_has
from attrs import resolve_types
from msgspec import Struct, convert, to_builtins
from msgspec.json import Encoder, decode

from .._compat import fields, get_args, get_origin, is_bare, is_mapping, is_sequence
from .._compat import (
fields,
get_args,
get_origin,
is_bare,
is_mapping,
is_sequence,
is_subclass,
)
from ..cols import is_namedtuple
from ..converters import BaseConverter, Converter
from ..dispatch import UnstructureHook
Expand Down Expand Up @@ -74,7 +83,9 @@ def configure_converter(converter: Converter) -> None:
configure_passthroughs(converter)

converter.register_unstructure_hook(Struct, to_builtins)
converter.register_unstructure_hook(Enum, identity)
converter.register_unstructure_hook_factory(
lambda t: is_subclass(t, Enum), lambda t, c: identity
)

converter.register_structure_hook(Struct, convert)
converter.register_structure_hook(bytes, lambda v, _: b64decode(v))
Expand Down
4 changes: 2 additions & 2 deletions src/cattrs/preconf/orjson.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ def key_handler(v):
),
]
)
converter.register_unstructure_hook_func(
partial(is_primitive_enum, include_bare_enums=True), identity
converter.register_unstructure_hook_factory(
partial(is_primitive_enum, include_bare_enums=True), lambda t: identity
)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
Expand Down
2 changes: 1 addition & 1 deletion src/cattrs/preconf/ujson.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ 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_func(is_primitive_enum, identity)
converter.register_unstructure_hook_factory(is_primitive_enum, lambda t: identity)
converter.register_unstructure_hook_factory(
is_literal_containing_enums, literals_with_enums_unstructure_factory
)
Expand Down
13 changes: 13 additions & 0 deletions tests/test_function_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,16 @@ class Bar(Foo):
assert dispatch.dispatch(Bar) == "foo"
dispatch.register(lambda cls: issubclass(cls, Bar), "bar")
assert dispatch.dispatch(Bar) == "bar"


def test_function_dispatch_exception():
"""Function dispatch gracefully handles exceptions in predicates."""
dispatch = FunctionDispatch(BaseConverter())

def raising_predicate(cls):
raise ValueError("This predicate raises an error")

dispatch.register(lambda cls: issubclass(cls, float), "float")
dispatch.register(raising_predicate, "error")

assert dispatch.dispatch(float) == "float"
Loading