Skip to content

Commit 39a4d6e

Browse files
Apply attrs' converter to default before omit_if_default check (#696)
* Apply attrs' converter to default before omit_if_default check * Fix ruff * Add a test * Add docs * Add more attrs to test
1 parent 9e0e6ee commit 39a4d6e

File tree

3 files changed

+89
-8
lines changed

3 files changed

+89
-8
lines changed

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
1717
([#688](https://github.com/python-attrs/cattrs/pull/688))
1818
- Python 3.9 is no longer supported, as it is end-of-life. Use previous versions on this Python version.
1919
([#698](https://github.com/python-attrs/cattrs/pull/698))
20+
- 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.
21+
([#696](https://github.com/python-attrs/cattrs/pull/696))
2022

2123
## 25.3.0 (2025-10-07)
2224

src/cattrs/gen/__init__.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from collections.abc import Callable, Iterable, Mapping
55
from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar
66

7-
from attrs import NOTHING, Attribute, Factory
7+
from attrs import NOTHING, Attribute, Converter, Factory
88
from typing_extensions import NoDefault
99

1010
from .._compat import (
@@ -99,6 +99,10 @@ def make_dict_unstructure_fn_from_attrs(
9999
.. versionchanged:: 25.2.0
100100
The `_cattrs_use_alias` parameter takes its value from the given converter
101101
by default.
102+
.. versionchanged:: NEXT
103+
When `_cattrs_omit_if_default` is true and the attribute has an attrs converter
104+
specified, the converter is applied to the default value before checking if it
105+
is equal to the attribute's value.
102106
"""
103107

104108
fn_name = "unstructure_" + cl.__name__
@@ -177,16 +181,32 @@ def make_dict_unstructure_fn_from_attrs(
177181
if isinstance(d, Factory):
178182
globs[def_name] = d.factory
179183
internal_arg_parts[def_name] = d.factory
180-
if d.takes_self:
181-
lines.append(f" if instance.{attr_name} != {def_name}(instance):")
182-
else:
183-
lines.append(f" if instance.{attr_name} != {def_name}():")
184-
lines.append(f" res['{kn}'] = {invoke}")
184+
def_str = f"{def_name}(instance)" if d.takes_self else f"{def_name}()"
185185
else:
186186
globs[def_name] = d
187187
internal_arg_parts[def_name] = d
188-
lines.append(f" if instance.{attr_name} != {def_name}:")
189-
lines.append(f" res['{kn}'] = {invoke}")
188+
def_str = def_name
189+
190+
c = a.converter
191+
if c is not None:
192+
conv_name = f"__c_conv_{attr_name}"
193+
if isinstance(c, Converter):
194+
globs[conv_name] = c
195+
internal_arg_parts[conv_name] = c
196+
field_name = f"__c_field_{attr_name}"
197+
globs[field_name] = a
198+
internal_arg_parts[field_name] = a
199+
def_str = f"{conv_name}({def_str}, instance, {field_name})"
200+
elif isinstance(d, Factory):
201+
globs[conv_name] = c
202+
internal_arg_parts[conv_name] = c
203+
def_str = f"{conv_name}({def_str})"
204+
else:
205+
globs[def_name] = c(d)
206+
internal_arg_parts[def_name] = c(d)
207+
208+
lines.append(f" if instance.{attr_name} != {def_str}:")
209+
lines.append(f" res['{kn}'] = {invoke}")
190210

191211
else:
192212
# No default or no override.

tests/test_converter.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Union,
1717
)
1818

19+
import attrs
1920
import pytest
2021
from attrs import Factory, define, field, fields, has, make_class
2122
from hypothesis import assume, given
@@ -339,6 +340,64 @@ class C:
339340
assert inst == converter.structure(unstructured, C)
340341

341342

343+
@given(simple_typed_classes(defaults="always", allow_nan=False))
344+
def test_omit_default_with_attrs_converter_roundtrip(cl_and_vals):
345+
"""
346+
Omit default with an attrs converter works.
347+
"""
348+
converter = Converter(omit_if_default=True)
349+
cl, vals, kwargs = cl_and_vals
350+
351+
@define
352+
class C:
353+
a1: int = field(default="1", converter=int)
354+
a2: int = field(default="1", converter=attrs.Converter(int))
355+
a3: int = field(factory=lambda: "1", converter=int)
356+
a4: int = field(factory=lambda: "1", converter=attrs.Converter(int))
357+
a5: int = field(
358+
factory=lambda: "2",
359+
converter=attrs.Converter(
360+
lambda obj, self: int(obj) + self.a4, takes_self=True
361+
),
362+
)
363+
a6: int = field(
364+
factory=lambda: "2",
365+
converter=attrs.Converter(
366+
lambda obj, field: int(obj) + int(field.default.factory()) - 2,
367+
takes_field=True,
368+
),
369+
)
370+
a7: int = field(
371+
factory=lambda: "3",
372+
converter=attrs.Converter(
373+
lambda obj, self, field: (
374+
int(obj) + self.a6 + int(field.default.factory()) - 3
375+
),
376+
takes_self=True,
377+
takes_field=True,
378+
),
379+
)
380+
c: cl = Factory(lambda: cl(*vals, **kwargs))
381+
382+
inst = C()
383+
unstructured = converter.unstructure(inst)
384+
assert unstructured == {}
385+
assert inst == converter.structure(unstructured, C)
386+
387+
inst = C(0, 0, 0, 0, 0, 0, 0)
388+
unstructured = converter.unstructure(inst)
389+
assert unstructured == {
390+
"a1": 0,
391+
"a2": 0,
392+
"a3": 0,
393+
"a4": 0,
394+
"a5": 0,
395+
"a6": 0,
396+
"a7": 0,
397+
}
398+
assert inst == converter.structure(unstructured, C)
399+
400+
342401
def test_dict_roundtrip_with_alias():
343402
"""
344403
A class with an aliased attribute can be unstructured and structured.

0 commit comments

Comments
 (0)