Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/240.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Converters can now be provided as a decorator to the field.
13 changes: 13 additions & 0 deletions docs/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 22 additions & 3 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2703,10 +2703,10 @@ class _CountingAttr:
"""

__slots__ = (
"_converter",
"_default",
"_validator",
"alias",
"converter",
"counter",
"eq",
"eq_key",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down
54 changes: 54 additions & 0 deletions tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
12 changes: 12 additions & 0 deletions tests/test_mypy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading