diff --git a/HISTORY.md b/HISTORY.md index a4464ea6..bfee121e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -23,6 +23,9 @@ Our backwards-compatibility policy can be found [here](https://github.com/python [PEP 747](https://peps.python.org/pep-0747/) [`TypeForm`](https://typing.python.org/en/latest/spec/type-forms.html#type-forms) annotations, allowing type checkers to infer result types for unions, literals, type aliases and other non-trivial types. ([#786](https://github.com/python-attrs/cattrs/pull/786)) +- Fix {meth}`BaseConverter.copy` and {meth}`Converter.copy` (and `copy.deepcopy`, which uses them) dropping the + `unstructure_fallback_factory` and `structure_fallback_factory` of the original converter, making copies of converters + configured with custom fallbacks behave differently from the originals. The factories can now also be overridden on the copy. ## 26.2.1 (2026-09-26) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index b537deac..2f338f1e 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1042,11 +1042,20 @@ def copy( unstruct_strat: UnstructureStrategy | None = None, prefer_attrib_converters: bool | None = None, detailed_validation: bool | None = None, + unstructure_fallback_factory: HookFactory[UnstructureHook] | None = None, + structure_fallback_factory: HookFactory[StructureHook] | None = None, ) -> Self: """Create a copy of the converter, keeping all existing custom hooks. :param detailed_validation: Whether to use a slightly slower mode for detailed validation errors. + :param unstructure_fallback_factory: A hook factory to be called when no + registered unstructuring hooks match. + :param structure_fallback_factory: A hook factory to be called when no + registered structuring hooks match. + + .. versionchanged:: NEXT + The fallback factories are now carried over to the copy. """ res = self.__class__( dict_factory if dict_factory is not None else self._dict_factory, @@ -1069,6 +1078,16 @@ def copy( if detailed_validation is not None else self.detailed_validation ), + ( + unstructure_fallback_factory + if unstructure_fallback_factory is not None + else self._unstructure_func._fallback_factory + ), + ( + structure_fallback_factory + if structure_fallback_factory is not None + else self._structure_func._fallback_factory + ), ) self._unstructure_func.copy_to(res._unstructure_func, self._unstruct_copy_skip) @@ -1462,11 +1481,20 @@ def copy( prefer_attrib_converters: bool | None = None, detailed_validation: bool | None = None, use_alias: bool | None = None, + unstructure_fallback_factory: HookFactory[UnstructureHook] | None = None, + structure_fallback_factory: HookFactory[StructureHook] | None = None, ) -> Self: """Create a copy of the converter, keeping all existing custom hooks. :param detailed_validation: Whether to use a slightly slower mode for detailed validation errors. + :param unstructure_fallback_factory: A hook factory to be called when no + registered unstructuring hooks match. + :param structure_fallback_factory: A hook factory to be called when no + registered structuring hooks match. + + .. versionchanged:: NEXT + The fallback factories are now carried over to the copy. """ res = self.__class__( dict_factory if dict_factory is not None else self._dict_factory, @@ -1502,6 +1530,16 @@ def copy( else self.detailed_validation ), use_alias=(use_alias if use_alias is not None else self.use_alias), + unstructure_fallback_factory=( + unstructure_fallback_factory + if unstructure_fallback_factory is not None + else self._unstructure_func._fallback_factory + ), + structure_fallback_factory=( + structure_fallback_factory + if structure_fallback_factory is not None + else self._structure_func._fallback_factory + ), ) self._unstructure_func.copy_to( diff --git a/tests/test_copy.py b/tests/test_copy.py index 43d045d3..a956c22c 100644 --- a/tests/test_copy.py +++ b/tests/test_copy.py @@ -18,6 +18,18 @@ class Simple: a: int +class Unhandled: + """A type no hook is generated or registered for.""" + + +def unstructure_fallback(_): + return lambda v: "unstructured-by-fallback" + + +def structure_fallback(_): + return lambda v, _type: "structured-by-fallback" + + @given(strat=unstructure_strats, detailed_validation=..., prefer_attrib=...) def test_deepcopy( converter_cls: type[BaseConverter], @@ -183,6 +195,64 @@ def test_copy_func_hooks( assert c._dict_factory == copy._dict_factory +@given( + strat=unstructure_strats, + detailed_validation=..., + prefer_attrib=..., + dict_factory=one_of(just(dict), just(OrderedDict)), +) +def test_copy_fallback_factories( + converter_cls: type[BaseConverter], + strat: UnstructureStrategy, + prefer_attrib: bool, + detailed_validation: bool, + dict_factory: Callable, +): + """Fallback hook factories are copied over.""" + c = converter_cls( + unstruct_strat=strat, + prefer_attrib_converters=prefer_attrib, + detailed_validation=detailed_validation, + dict_factory=dict_factory, + unstructure_fallback_factory=unstructure_fallback, + structure_fallback_factory=structure_fallback, + ) + + assert c.unstructure(Unhandled()) == "unstructured-by-fallback" + assert c.structure("x", Unhandled) == "structured-by-fallback" + + for copy in (c.copy(), deepcopy(c)): + assert c is not copy + + assert copy.unstructure(Unhandled()) == "unstructured-by-fallback" + assert copy.structure("x", Unhandled) == "structured-by-fallback" + + +@given(strat=unstructure_strats, detailed_validation=...) +def test_copy_fallback_factories_overrides( + strat: UnstructureStrategy, detailed_validation: bool +): + """Fallback hook factories can be overridden on the copy.""" + c = Converter( + unstruct_strat=strat, + detailed_validation=detailed_validation, + unstructure_fallback_factory=unstructure_fallback, + structure_fallback_factory=structure_fallback, + ) + + other = c.copy( + unstructure_fallback_factory=lambda _: lambda v: "other-unstructure", + structure_fallback_factory=lambda _: lambda v, _type: "other-structure", + ) + + assert other.unstructure(Unhandled()) == "other-unstructure" + assert other.structure("x", Unhandled) == "other-structure" + + # The original is untouched. + assert c.unstructure(Unhandled()) == "unstructured-by-fallback" + assert c.structure("x", Unhandled) == "structured-by-fallback" + + @given(prefer_attrib=..., dict_factory=one_of(just(dict), just(OrderedDict))) def test_detailed_validation(prefer_attrib: bool, dict_factory: Callable): """Copies with different detailed validation work correctly."""