From 1341d19af39742c7117cc5c09bc0df21cde552e6 Mon Sep 17 00:00:00 2001 From: Antony Frolov Date: Tue, 4 Nov 2025 22:21:58 +0000 Subject: [PATCH 1/3] Apply attrs' converter to default before omit_if_default check --- src/cattrs/gen/__init__.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 62e3a37a..12f0999b 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, Factory, Converter from typing_extensions import NoDefault from .._compat import ( @@ -178,15 +178,26 @@ def make_dict_unstructure_fn_from_attrs( 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):") + def_str = f"{def_name}(instance)" else: - lines.append(f" if instance.{attr_name} != {def_name}():") - lines.append(f" res['{kn}'] = {invoke}") + def_str = 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 isinstance(c, Converter): + conv_name = f"__c_conv_{attr_name}" + 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})" + + lines.append(f" if instance.{attr_name} != {def_str}:") + lines.append(f" res['{kn}'] = {invoke}") else: # No default or no override. From 478df66fcc0b520f43b29f144afb20cf005be754 Mon Sep 17 00:00:00 2001 From: Antony Frolov Date: Wed, 5 Nov 2025 09:25:23 +0000 Subject: [PATCH 2/3] Fix ruff --- src/cattrs/gen/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 12f0999b..2a408a05 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, Converter +from attrs import NOTHING, Attribute, Converter, Factory from typing_extensions import NoDefault from .._compat import ( @@ -177,10 +177,7 @@ 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: - def_str = f"{def_name}(instance)" - else: - def_str = f"{def_name}()" + 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 From 14e5ef776575fe3469d460fd26e96ae8f2de640f Mon Sep 17 00:00:00 2001 From: Anton Frolov Date: Sun, 9 Nov 2025 13:50:18 +0000 Subject: [PATCH 3/3] Add a test --- src/cattrs/gen/__init__.py | 13 ++++++++----- tests/test_converter.py | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 2a408a05..41d3c6e3 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -184,14 +184,17 @@ def make_dict_unstructure_fn_from_attrs( def_str = def_name c = a.converter - if isinstance(c, Converter): + if c is not None: conv_name = f"__c_conv_{attr_name}" 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})" + if isinstance(c, Converter): + 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})" + else: + def_str = f"{conv_name}({def_str})" lines.append(f" if instance.{attr_name} != {def_str}:") lines.append(f" res['{kn}'] = {invoke}") diff --git a/tests/test_converter.py b/tests/test_converter.py index 3c5cbe25..d2b7f6a1 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,31 @@ 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 attrs' converter on the 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)) + 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) + unstructured = converter.unstructure(inst) + assert unstructured == {"a1": 0, "a2": 0} + assert inst == converter.structure(unstructured, C) + + def test_dict_roundtrip_with_alias(): """ A class with an aliased attribute can be unstructured and structured.