Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
38 changes: 38 additions & 0 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
70 changes: 70 additions & 0 deletions tests/test_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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."""
Expand Down