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
8 changes: 6 additions & 2 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
and {func}`cattrs.gen.typeddicts.make_dict_structure_fn` will use the value for the `use_alias` parameter from the given converter by default now.
If you're using these functions directly, the old behavior can be restored by passing in the desired value directly.
([#596](https://github.com/python-attrs/cattrs/issues/596) [#660](https://github.com/python-attrs/cattrs/pull/660))
- The [union passthrough strategy](https://catt.rs/en/stable/strategies.html#union-passthrough) now by default accepts ints for unions that contain floats but not ints,
when configured to be able to handle both ints and floats.
This more closely matches the [current typing behavior](https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex).
([#656](https://github.com/python-attrs/cattrs/issues/656) [#668](https://github.com/python-attrs/cattrs/pull/668))
- Fix unstructuring of generic classes with stringified annotations.
([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662)
- For {class}`cattrs.errors.StructureHandlerNotFoundError` and {class}`cattrs.errors.ForbiddenExtraKeysError`
correctly set {attr}`BaseException.args` in `super()` and hence make them pickable.
- For {class}`cattrs.errors.StructureHandlerNotFoundError` and {class}`cattrs.errors.ForbiddenExtraKeysError`
correctly set {attr}`BaseException.args` in `super()` and hence make them pickable.
([#666](https://github.com/python-attrs/cattrs/pull/666))

## 25.1.1 (2025-06-04)
Expand Down
21 changes: 20 additions & 1 deletion src/cattrs/strategies/_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ def structure_tagged_union(
converter.register_structure_hook(union, structure_tagged_union)


def configure_union_passthrough(union: Any, converter: BaseConverter) -> None:
def configure_union_passthrough(
union: Any, converter: BaseConverter, accept_ints_as_floats: bool = True
) -> None:
"""
Configure the converter to support validating and passing through unions of the
provided types and their subsets.
Expand All @@ -162,7 +164,14 @@ def configure_union_passthrough(union: Any, converter: BaseConverter) -> None:
If the union contains a class and one or more of its subclasses, the subclasses
will also be included when validating the superclass.

:param accept_ints_as_floats: When set (the default), if the provided union
contains both ints and floats, actual unions containing only floats will also accept
ints. See https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex
for more information.

.. versionadded:: 23.2.0
.. versionchanged:: 25.2.0
Introduced the `accept_ints_as_floats` parameter.
"""
args = set(union.__args__)

Expand Down Expand Up @@ -205,6 +214,16 @@ def make_structure_native_union(exact_type: Any) -> Callable:
and not is_literal(a)
}

# By default, when floats are part of the union, accept ints too.
if (
accept_ints_as_floats
and int in args
and float in args
and float in non_literal_classes
and int not in non_literal_classes
):
non_literal_classes.add(int)

if spillover:
spillover_type = (
Union[tuple(spillover)] if len(spillover) > 1 else next(iter(spillover))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import pytest
from attrs import define

from cattrs import BaseConverter
from cattrs import BaseConverter, ClassValidationError, Converter
from cattrs.strategies import configure_union_passthrough


Expand Down Expand Up @@ -109,3 +109,35 @@ class B:

with pytest.raises(TypeError):
converter.structure((), union)


def test_int_is_float(converter: BaseConverter) -> None:
"""By default, ints can also be accepted when floats are.

When the strategy gets initialized with both ints and floats,
unions that only contain floats also accept ints by default.
"""

configure_union_passthrough(Union[int, float, str, None], converter)

assert converter.structure(1, Union[float, str, None]) == 1
assert isinstance(converter.structure(1, Union[float, str, None]), int)


def test_int_is_not_float(converter: BaseConverter) -> None:
"""Ints can be configured to be separate."""

@define
class A:
a: int

configure_union_passthrough(
Union[int, float], converter, accept_ints_as_floats=False
)

with pytest.raises(
ClassValidationError
if isinstance(converter, Converter) and converter.detailed_validation
else TypeError
):
converter.structure(1, Union[float, A])
9 changes: 3 additions & 6 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,12 @@ def test_errors_pickling(
before = err_cls(*err_args)

assert before.args == err_args

with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("wb") as f:
pickle.dump(before, f)

with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("rb") as f:
after = pickle.load(f) # noqa: S301
after = pickle.loads(pickle.dumps(before)) # noqa: S301

assert isinstance(after, err_cls)

assert str(after) == str(before)

if issubclass(err_cls, ExceptionGroup):
assert after.message == before.message
assert after.args[0] == before.args[0]
Expand Down