Skip to content

Commit

Permalink
converters: allow wrapping & takes_self
Browse files Browse the repository at this point in the history
  • Loading branch information
hynek committed Mar 17, 2024
1 parent 1b3898a commit 8eb29eb
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 58 deletions.
15 changes: 15 additions & 0 deletions docs/api.rst
Expand Up @@ -93,6 +93,21 @@ 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
2 changes: 2 additions & 0 deletions src/attr/__init__.py
Expand Up @@ -15,6 +15,7 @@
from ._make import (
NOTHING,
Attribute,
Converter,
Factory,
attrib,
attrs,
Expand All @@ -39,6 +40,7 @@ class AttrsInstance(Protocol):
__all__ = [
"Attribute",
"AttrsInstance",
"Converter",
"Factory",
"NOTHING",
"asdict",
Expand Down
36 changes: 33 additions & 3 deletions src/attr/_compat.py
Expand Up @@ -40,16 +40,46 @@ def __init__(self, callable):
except (ValueError, TypeError): # inspect failed
self.sig = None

def get_first_param_type(self):
def get_annotations_for_converter_callable(self):
"""
Return the type annotation of the first argument if it's not empty.
Return the annotations based on its return values and the signature of
its first argument.
"""
if not self.sig:
return {}

rv = {}

ret = self.get_return_type()
if ret is not None:
rv["return"] = ret

first_param = self.get_first_param()
if first_param is not None:
rv[first_param[0]] = first_param[1]

return rv

def get_first_param(self):
"""
Get the name and type annotation of the first argument as a tuple.
"""
if not self.sig:
return None

params = list(self.sig.parameters.values())
if params and params[0].annotation is not inspect.Parameter.empty:
return params[0].annotation
return params[0].name, params[0].annotation

return None

def get_first_param_type(self):
"""
Return the type annotation of the first argument if it's not empty.
"""
p = self.get_first_param()
if p:
return p[1]

return None

Expand Down
145 changes: 110 additions & 35 deletions src/attr/_make.py
Expand Up @@ -202,11 +202,17 @@ def attrib(
specified default value or factory.
.. seealso:: `init`
:param typing.Callable converter: `callable` that is called by
:param typing.Callable | Converter converter: `callable` that is called by
*attrs*-generated ``__init__`` methods to convert attribute's value to
the desired format. It is given the passed-in value, and the returned
value will be used as the new value of the attribute. The value is
converted before being passed to the validator, if any.
the desired format.
If a vanilla callable is passed, it is given the passed-in value as the
only positional argument. It is possible to receive additional
arguments by wrapping the callable in a `Converter`.
Either way, the returned value will be used as the new value of the
attribute. The value is converted before being passed to the
validator, if any.
.. seealso:: :ref:`converters`
:param dict | None metadata: An arbitrary mapping, to be used by
Expand Down Expand Up @@ -2208,7 +2214,7 @@ 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))" % (
return "_setattr('%s', %s(%s, self))" % (
attr_name,
_INIT_CONVERTER_PAT % (attr_name,),
value_var,
Expand All @@ -2234,7 +2240,7 @@ 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)" % (
return "self.%s = %s(%s, self)" % (
attr_name,
_INIT_CONVERTER_PAT % (attr_name,),
value_var,
Expand Down Expand Up @@ -2267,7 +2273,7 @@ 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)" % (
return "_inst_dict['%s'] = %s(%s, self)" % (
attr_name,
_INIT_CONVERTER_PAT % (attr_name,),
value_var,
Expand Down Expand Up @@ -2344,10 +2350,15 @@ def _attrs_to_init_script(
has_factory = isinstance(a.default, Factory)
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)
else:
converter = a.converter

if a.init is False:
if has_factory:
init_factory_name = _INIT_FACTORY_PAT % (a.name,)
if a.converter is not None:
if converter is not None:
lines.append(
fmt_setter_with_converter(
attr_name,
Expand All @@ -2356,7 +2367,7 @@ def _attrs_to_init_script(
)
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
a.converter
converter
)
else:
lines.append(
Expand All @@ -2367,17 +2378,15 @@ def _attrs_to_init_script(
)
)
names_for_globals[init_factory_name] = a.default.factory
elif a.converter is not None:
elif converter is not None:
lines.append(
fmt_setter_with_converter(
attr_name,
f"attr_dict['{attr_name}'].default",
has_on_setattr,
)
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
a.converter
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = converter
else:
lines.append(
fmt_setter(
Expand All @@ -2393,15 +2402,13 @@ def _attrs_to_init_script(
else:
args.append(arg)

if a.converter is not None:
if converter is not None:
lines.append(
fmt_setter_with_converter(
attr_name, arg_name, has_on_setattr
)
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
a.converter
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = converter
else:
lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))

Expand All @@ -2414,7 +2421,7 @@ def _attrs_to_init_script(
lines.append(f"if {arg_name} is not NOTHING:")

init_factory_name = _INIT_FACTORY_PAT % (a.name,)
if a.converter is not None:
if converter is not None:
lines.append(
" "
+ fmt_setter_with_converter(
Expand All @@ -2430,9 +2437,7 @@ def _attrs_to_init_script(
has_on_setattr,
)
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
a.converter
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = converter
else:
lines.append(
" " + fmt_setter(attr_name, arg_name, has_on_setattr)
Expand All @@ -2453,26 +2458,22 @@ def _attrs_to_init_script(
else:
args.append(arg_name)

if a.converter is not None:
if converter is not None:
lines.append(
fmt_setter_with_converter(
attr_name, arg_name, has_on_setattr
)
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
a.converter
)
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = converter
else:
lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))

if a.init is True:
if a.type is not None and a.converter is None:
if a.type is not None and converter is None:
annotations[arg_name] = a.type
elif a.converter is not None:
# Try to get the type from the converter.
t = _AnnotationExtractor(a.converter).get_first_param_type()
if t:
annotations[arg_name] = t
elif converter is not None and converter._first_param_type:
# Use the type from the converter if present.
annotations[arg_name] = converter._first_param_type

if attrs_to_validate: # we can skip this if there are no validators.
names_for_globals["_config"] = _config
Expand Down Expand Up @@ -2984,6 +2985,77 @@ def __setstate__(self, state):
Factory = _add_hash(_add_eq(_add_repr(Factory, attrs=_f), attrs=_f), attrs=_f)


class Converter:
"""
Stores a converter callable.
Allows for the wrapped converter to take additional arguments.
: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`)
.. versionadded:: 24.1.0
"""

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

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

ann = _AnnotationExtractor(converter)

self._first_param_type = ann.get_first_param_type()

# 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)

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

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}

def __setstate__(self, state):
"""
Load instance from state.
"""
self.__init__(**state)


_f = [
Attribute(
name=name,
default=NOTHING,
validator=None,
repr=True,
cmp=None,
eq=True,
order=False,
hash=True,
init=True,
inherited=False,
)
for name in ("converter", "takes_self")
]

Converter = _add_hash(
_add_eq(_add_repr(Converter, attrs=_f), attrs=_f), attrs=_f
)


def make_class(
name, attrs, bases=(object,), class_body=None, **attributes_arguments
):
Expand Down Expand Up @@ -3116,16 +3188,19 @@ def pipe(*converters):
.. versionadded:: 20.1.0
"""

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

return val

if not converters:
# If the converter list is empty, pipe_converter is the identity.
A = typing.TypeVar("A")
pipe_converter.__annotations__ = {"val": A, "return": A}
pipe_converter.__annotations__.update({"val": A, "return": A})
else:
# Get parameter type from first converter.
t = _AnnotationExtractor(converters[0]).get_first_param_type()
Expand All @@ -3137,4 +3212,4 @@ def pipe_converter(val):
if rt:
pipe_converter.__annotations__["return"] = rt

return pipe_converter
return Converter(pipe_converter, takes_self=True)
2 changes: 2 additions & 0 deletions src/attrs/__init__.py
Expand Up @@ -4,6 +4,7 @@
NOTHING,
Attribute,
AttrsInstance,
Converter,
Factory,
_make_getattr,
assoc,
Expand Down Expand Up @@ -42,6 +43,7 @@
"Attribute",
"AttrsInstance",
"cmp_using",
"Converter",
"converters",
"define",
"evolve",
Expand Down

0 comments on commit 8eb29eb

Please sign in to comment.