Skip to content

Commit 0f758fe

Browse files
LecrisUThynek
andauthored
Expose converter as a decorator (#1541)
* Expose converter as a decorator * Update docs/init.md * Update docs/init.md * Update src/attr/_make.py * Update src/attr/_make.py * Update src/attr/_make.py * Add missing import --------- Co-authored-by: Hynek Schlawack <hs@ox.cx>
1 parent 57b314c commit 0f758fe

File tree

5 files changed

+105
-3
lines changed

5 files changed

+105
-3
lines changed

changelog.d/240.change.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Converters can now be provided as a decorator to the field.

docs/init.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,21 @@ C(x=410)
385385
```
386386

387387

388+
Or as a decorator
389+
```{doctest}
390+
>>> from typing import ClassVar
391+
>>> @define
392+
... class C:
393+
... factor: ClassVar[int] = 5 # ClassVars are ignored by attrs
394+
... x: int = field(metadata={"offset": 200})
395+
... @x.converter
396+
... def _convert_x(self, attribute, value):
397+
... return int(value) * self.factor + attribute.metadata["offset"]
398+
>>> C("42")
399+
C(x=410)
400+
```
401+
402+
388403
## Hooking Yourself Into Initialization
389404

390405
Generally speaking, the moment you realize the need of finer control – than what *attrs* offers – over how a class is instantiated, it's usually best to use a {obj}`classmethod` factory or to apply the [builder pattern](https://en.wikipedia.org/wiki/Builder_pattern).

src/attr/_make.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,7 +2581,7 @@ def from_counting_attr(
25812581
False,
25822582
ca.metadata,
25832583
type,
2584-
ca.converter,
2584+
ca._converter,
25852585
kw_only if ca.kw_only is None else ca.kw_only,
25862586
ca.eq,
25872587
ca.eq_key,
@@ -2703,10 +2703,10 @@ class _CountingAttr:
27032703
"""
27042704

27052705
__slots__ = (
2706+
"_converter",
27062707
"_default",
27072708
"_validator",
27082709
"alias",
2709-
"converter",
27102710
"counter",
27112711
"eq",
27122712
"eq_key",
@@ -2794,7 +2794,7 @@ def __init__(
27942794
self.counter = _CountingAttr.cls_counter
27952795
self._default = default
27962796
self._validator = validator
2797-
self.converter = converter
2797+
self._converter = converter
27982798
self.repr = repr
27992799
self.eq = eq
28002800
self.eq_key = eq_key
@@ -2840,6 +2840,26 @@ def default(self, meth):
28402840

28412841
return meth
28422842

2843+
def converter(self, meth):
2844+
"""
2845+
Decorator that appends *meth* to the list of converters.
2846+
2847+
Returns *meth* unchanged.
2848+
2849+
.. versionadded:: 26.2.0
2850+
"""
2851+
decorated_converter = Converter(
2852+
lambda value, _self, field: meth(_self, field, value),
2853+
takes_self=True,
2854+
takes_field=True,
2855+
)
2856+
if self._converter is None:
2857+
self._converter = decorated_converter
2858+
else:
2859+
self._converter = pipe(self._converter, decorated_converter)
2860+
2861+
return meth
2862+
28432863

28442864
_CountingAttr = _add_eq(_add_repr(_CountingAttr))
28452865

tests/test_make.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,43 @@ def v2(self, _, __):
136136

137137
assert _AndValidator((v, v2)) == a._validator
138138

139+
def test_converter_decorator_single(self):
140+
"""
141+
If _CountingAttr.converter is used as a decorator and there is no
142+
decorator set, the decorated method is used as the converter.
143+
"""
144+
a = attr.ib()
145+
146+
@a.converter
147+
def v(self, value, field):
148+
pass
149+
150+
assert isinstance(a._converter, attr.Converter)
151+
assert a._converter.takes_self
152+
assert a._converter.takes_field
153+
154+
@pytest.mark.parametrize(
155+
"wrap", [lambda v: v, lambda v: [v], attr.converters.pipe]
156+
)
157+
def test_converter_decorator(self, wrap):
158+
"""
159+
If _CountingAttr.converter is used as a decorator and there is already
160+
a decorator set, the decorators are composed using `pipe`.
161+
"""
162+
163+
def v(_):
164+
pass
165+
166+
a = attr.ib(converter=wrap(v))
167+
168+
@a.converter
169+
def v2(self, value, field):
170+
pass
171+
172+
assert isinstance(a._converter, attr.Converter)
173+
assert a._converter.takes_self
174+
assert a._converter.takes_field
175+
139176
def test_default_decorator_already_set(self):
140177
"""
141178
Raise DefaultAlreadySetError if the decorator is used after a default
@@ -1720,6 +1757,23 @@ class C:
17201757

17211758
assert 84 == C(2).x
17221759

1760+
def test_converter_decorated(self):
1761+
"""
1762+
Same as Converter with both `takes_field` and `takes_self`
1763+
"""
1764+
1765+
@attr.define
1766+
class C:
1767+
factor: int = 5
1768+
x: int = attr.field(default=0, metadata={"offset": 200})
1769+
1770+
@x.converter
1771+
def _convert_x(self, field, value):
1772+
assert isinstance(field, attr.Attribute)
1773+
return int(value) * self.factor + field.metadata["offset"]
1774+
1775+
assert 410 == C(x="42").x
1776+
17231777
@given(integers(), booleans())
17241778
def test_convert_property(self, val, init):
17251779
"""

tests/test_mypy.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,18 @@
712712
C(42)
713713
C(43)
714714
715+
- case: testAttrsConverterDecorator
716+
main: |
717+
import attr
718+
@attr.s
719+
class C:
720+
x = attr.ib()
721+
@x.converter
722+
def convert(self, attribute, value):
723+
return value + 1
724+
725+
C(42)
726+
715727
- case: testAttrsLocalVariablesInClassMethod
716728
main: |
717729
import attr

0 commit comments

Comments
 (0)