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 57e132d0..7cc06d81 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. @@ -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__) @@ -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)) 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]) diff --git a/tests/test_errors.py b/tests/test_errors.py index fcea0a54..b7ab4707 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -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]