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
1 change: 1 addition & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion src/cattrs/strategies/_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,18 @@ 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:
return get_type_alias_base(type) if is_type_alias(type) else type

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__
Expand Down
76 changes: 75 additions & 1 deletion tests/strategies/test_union_passthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]
Expand Down