diff --git a/HISTORY.md b/HISTORY.md index 4339550f..e1b5298d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -17,6 +17,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#688](https://github.com/python-attrs/cattrs/pull/688)) - Python 3.9 is no longer supported, as it is end-of-life. Use previous versions on this Python version. ([#698](https://github.com/python-attrs/cattrs/pull/698)) +- Apply the attrs converter to the default value before checking if it is equal to the attribute's value, when `omit_if_default` is true and an attrs converter is specified. + ([#696](https://github.com/python-attrs/cattrs/pull/696)) ## 25.3.0 (2025-10-07) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 62e3a37a..3357aaca 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable, Mapping from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar -from attrs import NOTHING, Attribute, Factory +from attrs import NOTHING, Attribute, Converter, Factory from typing_extensions import NoDefault from .._compat import ( @@ -99,6 +99,10 @@ def make_dict_unstructure_fn_from_attrs( .. versionchanged:: 25.2.0 The `_cattrs_use_alias` parameter takes its value from the given converter by default. + .. versionchanged:: NEXT + When `_cattrs_omit_if_default` is true and the attribute has an attrs converter + specified, the converter is applied to the default value before checking if it + is equal to the attribute's value. """ fn_name = "unstructure_" + cl.__name__ @@ -177,16 +181,32 @@ def make_dict_unstructure_fn_from_attrs( if isinstance(d, Factory): globs[def_name] = d.factory internal_arg_parts[def_name] = d.factory - if d.takes_self: - lines.append(f" if instance.{attr_name} != {def_name}(instance):") - else: - lines.append(f" if instance.{attr_name} != {def_name}():") - lines.append(f" res['{kn}'] = {invoke}") + def_str = f"{def_name}(instance)" if d.takes_self else f"{def_name}()" else: globs[def_name] = d internal_arg_parts[def_name] = d - lines.append(f" if instance.{attr_name} != {def_name}:") - lines.append(f" res['{kn}'] = {invoke}") + def_str = def_name + + c = a.converter + if c is not None: + conv_name = f"__c_conv_{attr_name}" + if isinstance(c, Converter): + globs[conv_name] = c + internal_arg_parts[conv_name] = c + field_name = f"__c_field_{attr_name}" + globs[field_name] = a + internal_arg_parts[field_name] = a + def_str = f"{conv_name}({def_str}, instance, {field_name})" + elif isinstance(d, Factory): + globs[conv_name] = c + internal_arg_parts[conv_name] = c + def_str = f"{conv_name}({def_str})" + else: + globs[def_name] = c(d) + internal_arg_parts[def_name] = c(d) + + lines.append(f" if instance.{attr_name} != {def_str}:") + lines.append(f" res['{kn}'] = {invoke}") else: # No default or no override. diff --git a/tests/test_converter.py b/tests/test_converter.py index 3c5cbe25..ab86d55e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -16,6 +16,7 @@ Union, ) +import attrs import pytest from attrs import Factory, define, field, fields, has, make_class from hypothesis import assume, given @@ -339,6 +340,64 @@ class C: assert inst == converter.structure(unstructured, C) +@given(simple_typed_classes(defaults="always", allow_nan=False)) +def test_omit_default_with_attrs_converter_roundtrip(cl_and_vals): + """ + Omit default with an attrs converter works. + """ + converter = Converter(omit_if_default=True) + cl, vals, kwargs = cl_and_vals + + @define + class C: + a1: int = field(default="1", converter=int) + a2: int = field(default="1", converter=attrs.Converter(int)) + a3: int = field(factory=lambda: "1", converter=int) + a4: int = field(factory=lambda: "1", converter=attrs.Converter(int)) + a5: int = field( + factory=lambda: "2", + converter=attrs.Converter( + lambda obj, self: int(obj) + self.a4, takes_self=True + ), + ) + a6: int = field( + factory=lambda: "2", + converter=attrs.Converter( + lambda obj, field: int(obj) + int(field.default.factory()) - 2, + takes_field=True, + ), + ) + a7: int = field( + factory=lambda: "3", + converter=attrs.Converter( + lambda obj, self, field: ( + int(obj) + self.a6 + int(field.default.factory()) - 3 + ), + takes_self=True, + takes_field=True, + ), + ) + c: cl = Factory(lambda: cl(*vals, **kwargs)) + + inst = C() + unstructured = converter.unstructure(inst) + assert unstructured == {} + assert inst == converter.structure(unstructured, C) + + inst = C(0, 0, 0, 0, 0, 0, 0) + unstructured = converter.unstructure(inst) + assert unstructured == { + "a1": 0, + "a2": 0, + "a3": 0, + "a4": 0, + "a5": 0, + "a6": 0, + "a7": 0, + } + assert inst == converter.structure(unstructured, C) + + def test_dict_roundtrip_with_alias(): """ A class with an aliased attribute can be unstructured and structured.