From c752087b7908888bb6dd2a85e07e0da2a8dcf440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Fri, 4 Jul 2025 22:32:27 +0200 Subject: [PATCH 1/4] union passthrough: add `accept_ints_as_floats` --- src/cattrs/strategies/_unions.py | 14 +++++++- ...ve_unions.py => test_union_passthrough.py} | 34 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) rename tests/strategies/{test_native_unions.py => test_union_passthrough.py} (77%) diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 57e132d0..4c5e3dd1 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -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. @@ -205,6 +207,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)) diff --git a/tests/strategies/test_native_unions.py b/tests/strategies/test_union_passthrough.py similarity index 77% rename from tests/strategies/test_native_unions.py rename to tests/strategies/test_union_passthrough.py index 837831be..1dec27ae 100644 --- a/tests/strategies/test_native_unions.py +++ b/tests/strategies/test_union_passthrough.py @@ -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 @@ -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]) From a748a49f025dae34830d2058516172b3d2e98b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Fri, 4 Jul 2025 23:02:12 +0200 Subject: [PATCH 2/4] Work around flaky test --- tests/test_errors.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_errors.py b/tests/test_errors.py index fcea0a54..89d7b39f 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -42,15 +42,18 @@ def test_errors_pickling( before = err_cls(*err_args) assert before.args == err_args + after = pickle.loads(pickle.dumps(before)) # noqa: S301 - with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("wb") as f: - pickle.dump(before, f) + assert isinstance(after, err_cls) - with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("rb") as f: - after = pickle.load(f) # noqa: S301 + if err_cls is ForbiddenExtraKeysError and err_args[0] == "": + # This comparison is slightly tricky since this class uses sets, which do + # not have a stable ordering. + # We skip it and rely on comparing the args below. + pass + else: + assert str(after) == str(before) - 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] From 5db47d9f3697c1dfe726726f463a50614ce74ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 6 Jul 2025 17:57:39 +0200 Subject: [PATCH 3/4] Docs --- HISTORY.md | 8 ++++++-- src/cattrs/strategies/_unions.py | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 2bc9ab0a..382dfbcd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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) diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 4c5e3dd1..7cc06d81 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -164,7 +164,14 @@ def configure_union_passthrough( 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__) From d289c5acd31cdb236f4c02b3a825a7b2d16f0b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 6 Jul 2025 22:30:03 +0200 Subject: [PATCH 4/4] Revert flaky test workaround --- tests/test_errors.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_errors.py b/tests/test_errors.py index 89d7b39f..b7ab4707 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -46,13 +46,7 @@ def test_errors_pickling( assert isinstance(after, err_cls) - if err_cls is ForbiddenExtraKeysError and err_args[0] == "": - # This comparison is slightly tricky since this class uses sets, which do - # not have a stable ordering. - # We skip it and rely on comparing the args below. - pass - else: - assert str(after) == str(before) + assert str(after) == str(before) if issubclass(err_cls, ExceptionGroup): assert after.message == before.message