diff --git a/HISTORY.md b/HISTORY.md index a4464ea6..91167a4c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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+. diff --git a/src/cattrs/disambiguators.py b/src/cattrs/disambiguators.py index 451d5768..208bf604 100644 --- a/src/cattrs/disambiguators.py +++ b/src/cattrs/disambiguators.py @@ -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 @@ -14,7 +13,6 @@ from ._compat import ( NoneType, adapted_fields, - fields_dict, get_args, has, is_literal, @@ -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 diff --git a/tests/test_disambiguators.py b/tests/test_disambiguators.py index 66f366d8..37946b65 100644 --- a/tests/test_disambiguators.py +++ b/tests/test_disambiguators.py @@ -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 @@ -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."""