Skip to content

Commit 025f521

Browse files
authored
union passthrough: add accept_ints_as_floats (#668)
* union passthrough: add `accept_ints_as_floats` * Work around flaky test * Docs * Revert flaky test workaround
1 parent 591b4f7 commit 025f521

File tree

4 files changed

+62
-10
lines changed

4 files changed

+62
-10
lines changed

HISTORY.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
2323
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.
2424
If you're using these functions directly, the old behavior can be restored by passing in the desired value directly.
2525
([#596](https://github.com/python-attrs/cattrs/issues/596) [#660](https://github.com/python-attrs/cattrs/pull/660))
26+
- 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,
27+
when configured to be able to handle both ints and floats.
28+
This more closely matches the [current typing behavior](https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex).
29+
([#656](https://github.com/python-attrs/cattrs/issues/656) [#668](https://github.com/python-attrs/cattrs/pull/668))
2630
- Fix unstructuring of generic classes with stringified annotations.
2731
([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662)
28-
- For {class}`cattrs.errors.StructureHandlerNotFoundError` and {class}`cattrs.errors.ForbiddenExtraKeysError`
29-
correctly set {attr}`BaseException.args` in `super()` and hence make them pickable.
32+
- For {class}`cattrs.errors.StructureHandlerNotFoundError` and {class}`cattrs.errors.ForbiddenExtraKeysError`
33+
correctly set {attr}`BaseException.args` in `super()` and hence make them pickable.
3034
([#666](https://github.com/python-attrs/cattrs/pull/666))
3135

3236
## 25.1.1 (2025-06-04)

src/cattrs/strategies/_unions.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ def structure_tagged_union(
139139
converter.register_structure_hook(union, structure_tagged_union)
140140

141141

142-
def configure_union_passthrough(union: Any, converter: BaseConverter) -> None:
142+
def configure_union_passthrough(
143+
union: Any, converter: BaseConverter, accept_ints_as_floats: bool = True
144+
) -> None:
143145
"""
144146
Configure the converter to support validating and passing through unions of the
145147
provided types and their subsets.
@@ -162,7 +164,14 @@ def configure_union_passthrough(union: Any, converter: BaseConverter) -> None:
162164
If the union contains a class and one or more of its subclasses, the subclasses
163165
will also be included when validating the superclass.
164166
167+
:param accept_ints_as_floats: When set (the default), if the provided union
168+
contains both ints and floats, actual unions containing only floats will also accept
169+
ints. See https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex
170+
for more information.
171+
165172
.. versionadded:: 23.2.0
173+
.. versionchanged:: 25.2.0
174+
Introduced the `accept_ints_as_floats` parameter.
166175
"""
167176
args = set(union.__args__)
168177

@@ -205,6 +214,16 @@ def make_structure_native_union(exact_type: Any) -> Callable:
205214
and not is_literal(a)
206215
}
207216

217+
# By default, when floats are part of the union, accept ints too.
218+
if (
219+
accept_ints_as_floats
220+
and int in args
221+
and float in args
222+
and float in non_literal_classes
223+
and int not in non_literal_classes
224+
):
225+
non_literal_classes.add(int)
226+
208227
if spillover:
209228
spillover_type = (
210229
Union[tuple(spillover)] if len(spillover) > 1 else next(iter(spillover))

tests/strategies/test_native_unions.py renamed to tests/strategies/test_union_passthrough.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
from attrs import define
1111

12-
from cattrs import BaseConverter
12+
from cattrs import BaseConverter, ClassValidationError, Converter
1313
from cattrs.strategies import configure_union_passthrough
1414

1515

@@ -109,3 +109,35 @@ class B:
109109

110110
with pytest.raises(TypeError):
111111
converter.structure((), union)
112+
113+
114+
def test_int_is_float(converter: BaseConverter) -> None:
115+
"""By default, ints can also be accepted when floats are.
116+
117+
When the strategy gets initialized with both ints and floats,
118+
unions that only contain floats also accept ints by default.
119+
"""
120+
121+
configure_union_passthrough(Union[int, float, str, None], converter)
122+
123+
assert converter.structure(1, Union[float, str, None]) == 1
124+
assert isinstance(converter.structure(1, Union[float, str, None]), int)
125+
126+
127+
def test_int_is_not_float(converter: BaseConverter) -> None:
128+
"""Ints can be configured to be separate."""
129+
130+
@define
131+
class A:
132+
a: int
133+
134+
configure_union_passthrough(
135+
Union[int, float], converter, accept_ints_as_floats=False
136+
)
137+
138+
with pytest.raises(
139+
ClassValidationError
140+
if isinstance(converter, Converter) and converter.detailed_validation
141+
else TypeError
142+
):
143+
converter.structure(1, Union[float, A])

tests/test_errors.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,12 @@ def test_errors_pickling(
4242
before = err_cls(*err_args)
4343

4444
assert before.args == err_args
45-
46-
with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("wb") as f:
47-
pickle.dump(before, f)
48-
49-
with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("rb") as f:
50-
after = pickle.load(f) # noqa: S301
45+
after = pickle.loads(pickle.dumps(before)) # noqa: S301
5146

5247
assert isinstance(after, err_cls)
48+
5349
assert str(after) == str(before)
50+
5451
if issubclass(err_cls, ExceptionGroup):
5552
assert after.message == before.message
5653
assert after.args[0] == before.args[0]

0 commit comments

Comments
 (0)