From cfbd10c43a681c3964f046ef2b920d8fcdd8a3df Mon Sep 17 00:00:00 2001 From: Cristian Le Date: Mon, 13 Apr 2026 13:02:37 +0200 Subject: [PATCH 1/7] Expose converter as a decorator --- changelog.d/240.change.md | 1 + docs/init.md | 13 ++++++++++ src/attr/_make.py | 25 +++++++++++++++--- tests/test_make.py | 54 +++++++++++++++++++++++++++++++++++++++ tests/test_mypy.yml | 12 +++++++++ 5 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 changelog.d/240.change.md diff --git a/changelog.d/240.change.md b/changelog.d/240.change.md new file mode 100644 index 000000000..29522215d --- /dev/null +++ b/changelog.d/240.change.md @@ -0,0 +1 @@ +Converters can now be provided as a decorator to the field. diff --git a/docs/init.md b/docs/init.md index e4bb78e84..2312a14db 100644 --- a/docs/init.md +++ b/docs/init.md @@ -384,6 +384,19 @@ If you need more control over the conversion process, you can wrap the converter C(x=410) ``` +Or as a decorator +```{doctest} +>>> @define +... class C: +... factor = 5 # not an *attrs* field +... x: int = field(metadata={"offset": 200}) +... @x.converter +... def _convert_x(self, attribute, value): +... return int(value) * self.factor + attribute.metadata["offset"] +>>> C("42") +C(x=410) +``` + ## Hooking Yourself Into Initialization diff --git a/src/attr/_make.py b/src/attr/_make.py index 4b32d6a71..901ca25fa 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -2581,7 +2581,7 @@ def from_counting_attr( False, ca.metadata, type, - ca.converter, + ca._converter, kw_only if ca.kw_only is None else ca.kw_only, ca.eq, ca.eq_key, @@ -2703,10 +2703,10 @@ class _CountingAttr: """ __slots__ = ( + "_converter", "_default", "_validator", "alias", - "converter", "counter", "eq", "eq_key", @@ -2794,7 +2794,7 @@ def __init__( self.counter = _CountingAttr.cls_counter self._default = default self._validator = validator - self.converter = converter + self._converter = converter self.repr = repr self.eq = eq self.eq_key = eq_key @@ -2840,6 +2840,25 @@ def default(self, meth): return meth + def converter(self, meth): + """ + Decorator that adds *meth* to the list of converters. + + Returns *meth* unchanged. + + .. versionadded:: TBD + """ + decorated_converter = Converter( + lambda value, _self, field: meth(_self, field, value), + takes_self=True, + takes_field=True, + ) + if self._converter is None: + self._converter = decorated_converter + else: + self._converter = pipe(self._converter, decorated_converter) + return meth + _CountingAttr = _add_eq(_add_repr(_CountingAttr)) diff --git a/tests/test_make.py b/tests/test_make.py index 6f226c67e..f83f85234 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -136,6 +136,43 @@ def v2(self, _, __): assert _AndValidator((v, v2)) == a._validator + def test_converter_decorator_single(self): + """ + If _CountingAttr.converter is used as a decorator and there is no + decorator set, the decorated method is used as the converter. + """ + a = attr.ib() + + @a.converter + def v(self, value, field): + pass + + assert isinstance(a._converter, attr.Converter) + assert a._converter.takes_self + assert a._converter.takes_field + + @pytest.mark.parametrize( + "wrap", [lambda v: v, lambda v: [v], attr.converters.pipe] + ) + def test_converter_decorator(self, wrap): + """ + If _CountingAttr.converter is used as a decorator and there is already + a decorator set, the decorators are composed using `pipe`. + """ + + def v(_): + pass + + a = attr.ib(converter=wrap(v)) + + @a.converter + def v2(self, value, field): + pass + + assert isinstance(a._converter, attr.Converter) + assert a._converter.takes_self + assert a._converter.takes_field + def test_default_decorator_already_set(self): """ Raise DefaultAlreadySetError if the decorator is used after a default @@ -1720,6 +1757,23 @@ class C: assert 84 == C(2).x + def test_converter_decorated(self): + """ + Same as Converter with both `takes_field` and `takes_self` + """ + + @attr.define + class C: + factor: int = 5 + x: int = attr.field(default=0, metadata={"offset": 200}) + + @x.converter + def _convert_x(self, field, value): + assert isinstance(field, attr.Attribute) + return int(value) * self.factor + field.metadata["offset"] + + assert 410 == C(x="42").x + @given(integers(), booleans()) def test_convert_property(self, val, init): """ diff --git a/tests/test_mypy.yml b/tests/test_mypy.yml index 0c120f642..49a5c9dc3 100644 --- a/tests/test_mypy.yml +++ b/tests/test_mypy.yml @@ -712,6 +712,18 @@ C(42) C(43) +- case: testAttrsConverterDecorator + main: | + import attr + @attr.s + class C: + x = attr.ib() + @x.converter + def convert(self, attribute, value): + return value + 1 + + C(42) + - case: testAttrsLocalVariablesInClassMethod main: | import attr From 4df964337bc128bc397da0d63acaeb13e4f4ed36 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 16 Apr 2026 09:51:11 -0500 Subject: [PATCH 2/7] Update docs/init.md --- docs/init.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/init.md b/docs/init.md index 2312a14db..fb8dbf837 100644 --- a/docs/init.md +++ b/docs/init.md @@ -384,6 +384,7 @@ If you need more control over the conversion process, you can wrap the converter C(x=410) ``` + Or as a decorator ```{doctest} >>> @define From 50129c466e552607985c42b0f148ec9ed1c1c794 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 16 Apr 2026 09:51:17 -0500 Subject: [PATCH 3/7] Update docs/init.md --- docs/init.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/init.md b/docs/init.md index fb8dbf837..3c10b9718 100644 --- a/docs/init.md +++ b/docs/init.md @@ -389,7 +389,7 @@ Or as a decorator ```{doctest} >>> @define ... class C: -... factor = 5 # not an *attrs* field +... factor: ClassVar[int] = 5 # ClassVars are ignored by attrs ... x: int = field(metadata={"offset": 200}) ... @x.converter ... def _convert_x(self, attribute, value): From 6a4ccc3bab183ed3777383c5530dab1e1532b7da Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 16 Apr 2026 09:51:24 -0500 Subject: [PATCH 4/7] Update src/attr/_make.py --- src/attr/_make.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 901ca25fa..6497ba780 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -2846,7 +2846,7 @@ def converter(self, meth): Returns *meth* unchanged. - .. versionadded:: TBD + .. versionadded:: 26.2.0 """ decorated_converter = Converter( lambda value, _self, field: meth(_self, field, value), From 2f2555a09f623f591e2624cc8e3098807d02c6db Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 16 Apr 2026 09:51:38 -0500 Subject: [PATCH 5/7] Update src/attr/_make.py --- src/attr/_make.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index 6497ba780..b2fc81546 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -2857,6 +2857,7 @@ def converter(self, meth): self._converter = decorated_converter else: self._converter = pipe(self._converter, decorated_converter) + return meth From 13c9327fa8be967e9f79fa341ef92c260dff77cc Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 16 Apr 2026 09:51:47 -0500 Subject: [PATCH 6/7] Update src/attr/_make.py --- src/attr/_make.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index b2fc81546..793bfd89d 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -2842,7 +2842,7 @@ def default(self, meth): def converter(self, meth): """ - Decorator that adds *meth* to the list of converters. + Decorator that appends *meth* to the list of converters. Returns *meth* unchanged. From d54fd888d0cc774ddd8a91a1a1b12842cd72da58 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 16 Apr 2026 09:54:18 -0500 Subject: [PATCH 7/7] Add missing import --- docs/init.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/init.md b/docs/init.md index 3c10b9718..d6ec01be8 100644 --- a/docs/init.md +++ b/docs/init.md @@ -387,6 +387,7 @@ C(x=410) Or as a decorator ```{doctest} +>>> from typing import ClassVar >>> @define ... class C: ... factor: ClassVar[int] = 5 # ClassVars are ignored by attrs