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
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python

## NEXT

- Fix union disambiguation treating a dataclass field that sets only `default_factory` as a required field, so a payload omitting it failed to structure as that class. Such fields are now recognized as having a default, matching attrs `factory` fields.
([#796](https://github.com/python-attrs/cattrs/issues/796))
- 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.
([#238](https://github.com/python-attrs/cattrs/issues/238))
- Support the [`frozendict`](https://docs.python.org/3.15/builtins/stdtypes.html#frozendict) built-in on Python 3.15+.
Expand Down
12 changes: 7 additions & 5 deletions src/cattrs/disambiguators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from collections import defaultdict
from collections.abc import Mapping
from dataclasses import MISSING
from functools import reduce
from operator import or_
from typing import TYPE_CHECKING, Any, Callable, Literal, Union, get_origin
Expand All @@ -14,7 +13,6 @@
from ._compat import (
NoneType,
adapted_fields,
fields_dict,
get_args,
has,
is_literal,
Expand Down Expand Up @@ -150,11 +148,15 @@ def dis_func(data: Mapping[Any, Any]) -> type | None:
)
uniq = cl_reqs - other_reqs

# We want a unique attribute with no default.
cl_fields = fields_dict(get_origin(cl) or cl)
# We want a unique attribute with no default. Use `adapted_fields`
# so dataclass `default_factory` fields are seen as having a
# default (their raw `Field.default` is `MISSING`), matching attrs.
cl_defaults = {
at.name: at.default for at in adapted_fields(get_origin(cl) or cl)
}
for maybe_renamed_attr_name in uniq:
orig_name = back_map[maybe_renamed_attr_name]
if cl_fields[orig_name].default in (NOTHING, MISSING):
if cl_defaults[orig_name] is NOTHING:
uniq_attrs_dict[maybe_renamed_attr_name] = cl
made_progress = True
break
Expand Down
27 changes: 27 additions & 0 deletions tests/test_disambiguators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for auto-disambiguators."""

from dataclasses import dataclass
from dataclasses import field as dc_field
from functools import partial
from itertools import permutations
from typing import Literal, Union
Expand Down Expand Up @@ -482,6 +483,32 @@ class B:
assert converter.structure({"b": 1}, Union[A, B]) == B(1)


def test_dataclasses_default_factory(converter):
"""A dataclass `default_factory` field is not a required field (#796).

A raw `dataclasses.Field.default` is `MISSING` when only `default_factory`
is set, so such a field used to be treated as required. It should instead
be treated like an attrs `factory` field: the class carries no required
field and so becomes the disambiguation fallback, rather than the factory
field being picked as a unique required key.
"""

@dataclass
class A:
tags: list = dc_field(default_factory=list)

@define
class B:
b: int

# `A` has only a defaulted field, so it is the fallback: a payload without
# `b` structures as `A`, honoring the factory default, in either order.
for union in (Union[A, B], Union[B, A]):
assert converter.structure({}, union) == A()
assert converter.structure({"tags": [1]}, union) == A([1])
assert converter.structure({"b": 1}, union) == B(1)


def test_dataclasses_literals(converter):
"""The default strategy works for dataclasses too."""

Expand Down