Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Comment thread
hynek marked this conversation as resolved.
```{doctest}
>>> @define
... class C:
... factor = 5 # not an *attrs* field
Comment thread
hynek marked this conversation as resolved.
Outdated
... 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.
Comment thread
hynek marked this conversation as resolved.
Outdated

Returns *meth* unchanged.

.. versionadded:: TBD
Comment thread
hynek marked this conversation as resolved.
Outdated
"""
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)
Comment thread
hynek marked this conversation as resolved.
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