diff --git a/HISTORY.md b/HISTORY.md index e2e023d1..75df4c95 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- Fix the union passthrough strategy using the original converter's hooks and settings to structure spillover types after copying a converter. - The msgspec converter now applies the same unstructuring hook to nested instances of recursive _attrs_ classes, so registered hooks and private attributes are no longer skipped below the top level. ([#803](https://github.com/python-attrs/cattrs/pull/803)) - Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. diff --git a/docs/strategies.md b/docs/strategies.md index 7ed68afb..5709e4cc 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -376,6 +376,11 @@ Unions containing unsupported types can be handled if at least one union type is For example, if `A` and `B` are arbitrary _attrs_ classes, the union `Literal[10] | A | B` cannot be handled directly by a JSON converter. However, the strategy will check if the value being structured matches `Literal[10]` (because this type _is_ supported) and, if not, will pass it back to the converter to be structured as `A | B` (where a different strategy can handle it). +```{versionchanged} NEXT +Converters copied with `copy()` or `copy.deepcopy()` now use their own hooks and settings when structuring spillover types. +Previously, spillover was structured by the converter on which the strategy was first configured. +``` + The strategy is designed to run in _O(1)_ at structure time; it doesn't depend on the size of the union and the ordering of union members. This strategy has been preapplied to the following preconfigured converters: diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 52ac6a95..e7054ae2 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -179,6 +179,7 @@ def configure_union_passthrough( .. versionchanged:: NEXT Unions containing Python 3.15+ sentinels defer to sentinel handling, matching sentinel names before passing through other values. + Copied converters use their own hooks and settings for spillover types. """ def get_passthrough_base(type: Any) -> Any: @@ -186,7 +187,10 @@ def get_passthrough_base(type: Any) -> Any: args = {get_passthrough_base(type) for type in union.__args__} - def make_structure_native_union(exact_type: Any) -> Callable: + def make_structure_native_union( + exact_type: Any, converter: BaseConverter + ) -> Callable: + # Use the converter supplied by dispatch so copies handle their own spillover. # `exact_type` is likely to be a subset of the entire configured union (`args`). literal_values = { v for t in exact_type.__args__ if is_literal(t) for v in t.__args__ diff --git a/tests/strategies/test_union_passthrough.py b/tests/strategies/test_union_passthrough.py index f61ae4ca..60c46f7c 100644 --- a/tests/strategies/test_union_passthrough.py +++ b/tests/strategies/test_union_passthrough.py @@ -4,12 +4,20 @@ preconf tests. """ +from copy import deepcopy +from datetime import datetime from typing import List, Literal, Optional, Union import pytest from attrs import define -from cattrs import BaseConverter, ClassValidationError, Converter +from cattrs import ( + BaseConverter, + ClassValidationError, + Converter, + ForbiddenExtraKeysError, +) +from cattrs.preconf.json import make_converter from cattrs.strategies import configure_union_passthrough @@ -79,6 +87,72 @@ def test_spillover(converter: BaseConverter) -> None: converter.structure((), union) +@pytest.mark.parametrize("copy_method", ["copy", "deepcopy"]) +def test_copy_spillover_hooks(converter: BaseConverter, copy_method: str) -> None: + """A copy uses its own spillover hooks, including after a union was cached.""" + + @define + class A: + value: str + + configure_union_passthrough(Union[int, str, None], converter) + converter.register_structure_hook(A, lambda value, _: A(f"original:{value}")) + union = Union[int, A, None] + + assert converter.structure("value", union) == A("original:value") + copied = converter.copy() if copy_method == "copy" else deepcopy(converter) + assert copied.structure("value", union) == A("original:value") + + copied.register_structure_hook(A, lambda value, _: A(f"copy:{value}")) + + assert copied.structure("value", union) == A("copy:value") + assert converter.structure("value", union) == A("original:value") + assert copied.structure(1, union) == 1 + assert copied.structure(None, union) is None + + +def test_copy_spillover_forbid_extra_keys(genconverter: Converter) -> None: + """Configuration overridden on a copy also applies to union spillover.""" + + @define + class A: + value: int + + configure_union_passthrough(Union[int, str], genconverter) + union = Union[int, A] + payload = {"value": 1, "extra": 2} + assert genconverter.structure(payload, union) == A(1) + + copied = genconverter.copy(forbid_extra_keys=True) + assert copied.structure({"value": 1}, union) == A(1) + + with pytest.raises( + ClassValidationError if copied.detailed_validation else ForbiddenExtraKeysError + ): + copied.structure(payload, union) + + assert genconverter.structure(payload, union) == A(1) + assert copied.structure(1, union) == 1 + + +def test_copy_json_spillover() -> None: + """A copied JSON converter uses its datetime hook when loading a union.""" + converter = make_converter() + union = Union[int, datetime, None] + expected = datetime(2000, 1, 2) + assert converter.loads('"2000-01-02T00:00:00"', union) == expected + + copied = converter.copy() + copied.register_structure_hook( + datetime, lambda value, _: datetime.fromisoformat(value.replace("/", "-")) + ) + + assert copied.loads('"2000/01/02"', union) == expected + assert converter.loads('"2000-01-02T00:00:00"', union) == expected + assert copied.loads("1", union) == 1 + assert copied.loads("null", union) is None + + def test_multiple_spillover(converter: BaseConverter) -> None: """Types not covered by the native union are correctly handled.""" union = Union[int, str, None]