From 672c58715ed66550db674b16bc02da3e8fee14a6 Mon Sep 17 00:00:00 2001 From: simpleqt <2015900818@qq.com> Date: Tue, 20 Oct 2026 17:59:21 +0800 Subject: [PATCH] fix: carry over the fallback hook factories in Converter.copy() BaseConverter.copy() and Converter.copy() (and therefore copy.deepcopy(), which delegates to copy()) silently dropped the converter's unstructure_fallback_factory and structure_fallback_factory. A copy of a converter configured with custom fallbacks fell back to the built-in defaults instead: unstructuring a type with no hooks returned the object unchanged rather than calling the custom factory, and structuring such a type raised StructureHandlerNotFoundError. Unlike the other constructor options, the two factories had no way of being recovered from the converter being copied, so the copy read them from the dispatch objects that already hold them: MultiStrategyDispatch.__init__ stores the factory it is given in _fallback_factory, and BaseConverter.__init__ builds both dispatch objects from the constructor arguments. No new state is introduced. Fixes the same class of bug as #398/#399 and #410. Signed-off-by: simpleqt <2015900818@qq.com> --- HISTORY.md | 3 ++ src/cattrs/converters.py | 38 ++++++++++++++++++++++ tests/test_copy.py | 70 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) 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."""