Skip to content

Commit

Permalink
Add takes field
Browse files Browse the repository at this point in the history
  • Loading branch information
hynek committed Mar 17, 2024
1 parent 8eb29eb commit 89be1e4
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 43 deletions.
36 changes: 21 additions & 15 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,6 @@ Core
>>> C([1, 2, 3])
C(x=[1, 2, 3], y={1, 2, 3})

.. autoclass:: Converter

For example:

.. doctest::

>>> def complicated(value, self_):
... return int(value) * self_.factor
>>> @define
... class C:
... factor = 5 # not an *attrs* field
... x = field(converter=attrs.Converter(complicated, takes_self=True))
>>> C("42")
C(x=210)


Exceptions
----------
Expand Down Expand Up @@ -622,6 +607,27 @@ Validators can be both globally and locally disabled:
Converters
----------

.. autoclass:: attrs.Converter

For example:

.. doctest::

>>> def complicated(value, self_, field):
... return int(value) * self_.factor + field.metadata["offset"]
>>> @define
... class C:
... factor = 5 # not an *attrs* field
... x = field(
... metadata={"offset": 200},
... converter=attrs.Converter(
... complicated,
... takes_self=True, takes_field=True
... ))
>>> C("42")
C(x=410)


.. module:: attrs.converters

All objects from ``attrs.converters`` are also available from ``attr.converters`` (it's the same module in a different namespace).
Expand Down
64 changes: 42 additions & 22 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -2214,10 +2214,11 @@ def _setattr_with_converter(attr_name, value_var, has_on_setattr):
Use the cached object.setattr to set *attr_name* to *value_var*, but run
its converter first.
"""
return "_setattr('%s', %s(%s, self))" % (
return "_setattr('%s', %s(%s, self, attr_dict['%s']))" % (
attr_name,
_INIT_CONVERTER_PAT % (attr_name,),
value_var,
attr_name,
)


Expand All @@ -2240,10 +2241,11 @@ def _assign_with_converter(attr_name, value_var, has_on_setattr):
if has_on_setattr:
return _setattr_with_converter(attr_name, value_var, True)

return "self.%s = %s(%s, self)" % (
return "self.%s = %s(%s, self, attr_dict['%s'])" % (
attr_name,
_INIT_CONVERTER_PAT % (attr_name,),
value_var,
attr_name,
)


Expand Down Expand Up @@ -2273,10 +2275,11 @@ def fmt_setter_with_converter(attr_name, value_var, has_on_setattr):
attr_name, value_var, has_on_setattr
)

return "_inst_dict['%s'] = %s(%s, self)" % (
return "_inst_dict['%s'] = %s(%s, self, attr_dict['%s'])" % (
attr_name,
_INIT_CONVERTER_PAT % (attr_name,),
value_var,
attr_name,
)

return (
Expand Down Expand Up @@ -2351,7 +2354,7 @@ def _attrs_to_init_script(
maybe_self = "self" if has_factory and a.default.takes_self else ""

if a.converter and not isinstance(a.converter, Converter):
converter = Converter(a.converter, takes_self=False)
converter = Converter(a.converter)
else:
converter = a.converter

Expand Down Expand Up @@ -2989,44 +2992,61 @@ class Converter:
"""
Stores a converter callable.
Allows for the wrapped converter to take additional arguments.
Allows for the wrapped converter to take additional arguments. The
arguments are passed in the order they are documented.
:param Callable converter: A callable that converts a value.
:param bool takes_self: Pass the partially initialized instance that is
being initialized as a positional argument. (default: `True`)
being initialized as a positional argument. (default: `False`)
:param bool takes_field: Pass the field definition (an `Attribute`) into
the converter as a positional argument. (default: `False`)
.. versionadded:: 24.1.0
"""

__slots__ = ("converter", "takes_self", "_first_param_type", "__call__")
__slots__ = (
"converter",
"takes_self",
"takes_field",
"_first_param_type",
"__call__",
)

def __init__(self, converter, *, takes_self=True):
def __init__(self, converter, *, takes_self=False, takes_field=False):
self.converter = converter
self.takes_self = takes_self

ann = _AnnotationExtractor(converter)

self._first_param_type = ann.get_first_param_type()
self.takes_field = takes_field

# Defining __call__ as a regular method leads to __annotations__ being
# overwritten at a class level.
def __call__(value, inst):
if not self.takes_self:
return self.converter(value)

return self.converter(value, inst)
def __call__(value, inst, field):
return self.converter(
*{
(False, False): (value,),
(True, False): (value, inst),
(False, True): (value, field),
(True, True): (value, inst, field),
}[(takes_self, takes_field)]
)

ann = _AnnotationExtractor(converter)
__call__.__annotations__.update(
ann.get_annotations_for_converter_callable()
)
self.__call__ = __call__

self._first_param_type = ann.get_first_param_type()

def __getstate__(self):
"""
Return a dict containing only converter and takes_self -- the rest gets
computed when loading.
"""
return {"converter": self.converter, "takes_self": self.takes_self}
return {
"converter": self.converter,
"takes_self": self.takes_self,
"takes_field": self.takes_field,
}

def __setstate__(self, state):
"""
Expand All @@ -3048,7 +3068,7 @@ def __setstate__(self, state):
init=True,
inherited=False,
)
for name in ("converter", "takes_self")
for name in ("converter", "takes_self", "takes_field")
]

Converter = _add_hash(
Expand Down Expand Up @@ -3188,10 +3208,10 @@ def pipe(*converters):
.. versionadded:: 20.1.0
"""

def pipe_converter(val, inst):
def pipe_converter(val, inst, field):
for converter in converters:
if isinstance(converter, Converter):
val = converter(val, inst)
val = converter(val, inst, field)
else:
val = converter(val)

Expand All @@ -3212,4 +3232,4 @@ def pipe_converter(val, inst):
if rt:
pipe_converter.__annotations__["return"] = rt

return Converter(pipe_converter, takes_self=True)
return Converter(pipe_converter, takes_self=True, takes_field=True)
10 changes: 5 additions & 5 deletions tests/test_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ def test_success(self):
"""
Succeeds if all wrapped converters succeed.
"""
c = pipe(str, Converter(to_bool, takes_self=False), bool)
c = pipe(str, Converter(to_bool), bool)

assert True is c("True", None) is c(True, None)
assert True is c("True", None, None) is c(True, None, None)

def test_fail(self):
"""
Expand All @@ -128,11 +128,11 @@ def test_fail(self):

# First wrapped converter fails:
with pytest.raises(ValueError):
c(33, None)
c(33, None, None)

# Last wrapped converter fails:
with pytest.raises(ValueError):
c("33", None)
c("33", None, None)

def test_sugar(self):
"""
Expand All @@ -153,7 +153,7 @@ def test_empty(self):
"""
o = object()

assert o is pipe()(o, None)
assert o is pipe()(o, None, None)


class TestToBool:
Expand Down
23 changes: 22 additions & 1 deletion tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -1284,7 +1284,7 @@ class TestConverter:
Tests for attribute conversion.
"""

def test_convert(self):
def test_converter(self):
"""
Return value of converter is used as the attribute's value.
"""
Expand Down Expand Up @@ -1315,6 +1315,27 @@ class C:

assert 84 == C(2).x

def test_converter_wrapped_takes_field(self):
"""
When wrapped and passed `takes_field`, the converter receives the field
definition -- and the return value is used as the field's value.
"""

def converter_with_field(v, field):
assert isinstance(field, attr.Attribute)
return v * field.metadata["x"]

@attr.define
class C:
x: int = attr.field(
converter=attr.Converter(
converter_with_field, takes_field=True
),
metadata={"x": 42},
)

assert 84 == C(2).x

@given(integers(), booleans())
def test_convert_property(self, val, init):
"""
Expand Down

0 comments on commit 89be1e4

Please sign in to comment.