Skip to content

Commit 8eb29eb

Browse files
committed
converters: allow wrapping & takes_self
1 parent 1b3898a commit 8eb29eb

File tree

8 files changed

+217
-58
lines changed

8 files changed

+217
-58
lines changed

docs/api.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ Core
9393
>>> C([1, 2, 3])
9494
C(x=[1, 2, 3], y={1, 2, 3})
9595

96+
.. autoclass:: Converter
97+
98+
For example:
99+
100+
.. doctest::
101+
102+
>>> def complicated(value, self_):
103+
... return int(value) * self_.factor
104+
>>> @define
105+
... class C:
106+
... factor = 5 # not an *attrs* field
107+
... x = field(converter=attrs.Converter(complicated, takes_self=True))
108+
>>> C("42")
109+
C(x=210)
110+
96111

97112
Exceptions
98113
----------

src/attr/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ._make import (
1616
NOTHING,
1717
Attribute,
18+
Converter,
1819
Factory,
1920
attrib,
2021
attrs,
@@ -39,6 +40,7 @@ class AttrsInstance(Protocol):
3940
__all__ = [
4041
"Attribute",
4142
"AttrsInstance",
43+
"Converter",
4244
"Factory",
4345
"NOTHING",
4446
"asdict",

src/attr/_compat.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,46 @@ def __init__(self, callable):
4040
except (ValueError, TypeError): # inspect failed
4141
self.sig = None
4242

43-
def get_first_param_type(self):
43+
def get_annotations_for_converter_callable(self):
4444
"""
45-
Return the type annotation of the first argument if it's not empty.
45+
Return the annotations based on its return values and the signature of
46+
its first argument.
47+
"""
48+
if not self.sig:
49+
return {}
50+
51+
rv = {}
52+
53+
ret = self.get_return_type()
54+
if ret is not None:
55+
rv["return"] = ret
56+
57+
first_param = self.get_first_param()
58+
if first_param is not None:
59+
rv[first_param[0]] = first_param[1]
60+
61+
return rv
62+
63+
def get_first_param(self):
64+
"""
65+
Get the name and type annotation of the first argument as a tuple.
4666
"""
4767
if not self.sig:
4868
return None
4969

5070
params = list(self.sig.parameters.values())
5171
if params and params[0].annotation is not inspect.Parameter.empty:
52-
return params[0].annotation
72+
return params[0].name, params[0].annotation
73+
74+
return None
75+
76+
def get_first_param_type(self):
77+
"""
78+
Return the type annotation of the first argument if it's not empty.
79+
"""
80+
p = self.get_first_param()
81+
if p:
82+
return p[1]
5383

5484
return None
5585

src/attr/_make.py

Lines changed: 110 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,17 @@ def attrib(
202202
specified default value or factory.
203203
204204
.. seealso:: `init`
205-
:param typing.Callable converter: `callable` that is called by
205+
:param typing.Callable | Converter converter: `callable` that is called by
206206
*attrs*-generated ``__init__`` methods to convert attribute's value to
207-
the desired format. It is given the passed-in value, and the returned
208-
value will be used as the new value of the attribute. The value is
209-
converted before being passed to the validator, if any.
207+
the desired format.
208+
209+
If a vanilla callable is passed, it is given the passed-in value as the
210+
only positional argument. It is possible to receive additional
211+
arguments by wrapping the callable in a `Converter`.
212+
213+
Either way, the returned value will be used as the new value of the
214+
attribute. The value is converted before being passed to the
215+
validator, if any.
210216
211217
.. seealso:: :ref:`converters`
212218
:param dict | None metadata: An arbitrary mapping, to be used by
@@ -2208,7 +2214,7 @@ def _setattr_with_converter(attr_name, value_var, has_on_setattr):
22082214
Use the cached object.setattr to set *attr_name* to *value_var*, but run
22092215
its converter first.
22102216
"""
2211-
return "_setattr('%s', %s(%s))" % (
2217+
return "_setattr('%s', %s(%s, self))" % (
22122218
attr_name,
22132219
_INIT_CONVERTER_PAT % (attr_name,),
22142220
value_var,
@@ -2234,7 +2240,7 @@ def _assign_with_converter(attr_name, value_var, has_on_setattr):
22342240
if has_on_setattr:
22352241
return _setattr_with_converter(attr_name, value_var, True)
22362242

2237-
return "self.%s = %s(%s)" % (
2243+
return "self.%s = %s(%s, self)" % (
22382244
attr_name,
22392245
_INIT_CONVERTER_PAT % (attr_name,),
22402246
value_var,
@@ -2267,7 +2273,7 @@ def fmt_setter_with_converter(attr_name, value_var, has_on_setattr):
22672273
attr_name, value_var, has_on_setattr
22682274
)
22692275

2270-
return "_inst_dict['%s'] = %s(%s)" % (
2276+
return "_inst_dict['%s'] = %s(%s, self)" % (
22712277
attr_name,
22722278
_INIT_CONVERTER_PAT % (attr_name,),
22732279
value_var,
@@ -2344,10 +2350,15 @@ def _attrs_to_init_script(
23442350
has_factory = isinstance(a.default, Factory)
23452351
maybe_self = "self" if has_factory and a.default.takes_self else ""
23462352

2353+
if a.converter and not isinstance(a.converter, Converter):
2354+
converter = Converter(a.converter, takes_self=False)
2355+
else:
2356+
converter = a.converter
2357+
23472358
if a.init is False:
23482359
if has_factory:
23492360
init_factory_name = _INIT_FACTORY_PAT % (a.name,)
2350-
if a.converter is not None:
2361+
if converter is not None:
23512362
lines.append(
23522363
fmt_setter_with_converter(
23532364
attr_name,
@@ -2356,7 +2367,7 @@ def _attrs_to_init_script(
23562367
)
23572368
)
23582369
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
2359-
a.converter
2370+
converter
23602371
)
23612372
else:
23622373
lines.append(
@@ -2367,17 +2378,15 @@ def _attrs_to_init_script(
23672378
)
23682379
)
23692380
names_for_globals[init_factory_name] = a.default.factory
2370-
elif a.converter is not None:
2381+
elif converter is not None:
23712382
lines.append(
23722383
fmt_setter_with_converter(
23732384
attr_name,
23742385
f"attr_dict['{attr_name}'].default",
23752386
has_on_setattr,
23762387
)
23772388
)
2378-
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
2379-
a.converter
2380-
)
2389+
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = converter
23812390
else:
23822391
lines.append(
23832392
fmt_setter(
@@ -2393,15 +2402,13 @@ def _attrs_to_init_script(
23932402
else:
23942403
args.append(arg)
23952404

2396-
if a.converter is not None:
2405+
if converter is not None:
23972406
lines.append(
23982407
fmt_setter_with_converter(
23992408
attr_name, arg_name, has_on_setattr
24002409
)
24012410
)
2402-
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
2403-
a.converter
2404-
)
2411+
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = converter
24052412
else:
24062413
lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))
24072414

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

24162423
init_factory_name = _INIT_FACTORY_PAT % (a.name,)
2417-
if a.converter is not None:
2424+
if converter is not None:
24182425
lines.append(
24192426
" "
24202427
+ fmt_setter_with_converter(
@@ -2430,9 +2437,7 @@ def _attrs_to_init_script(
24302437
has_on_setattr,
24312438
)
24322439
)
2433-
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
2434-
a.converter
2435-
)
2440+
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = converter
24362441
else:
24372442
lines.append(
24382443
" " + fmt_setter(attr_name, arg_name, has_on_setattr)
@@ -2453,26 +2458,22 @@ def _attrs_to_init_script(
24532458
else:
24542459
args.append(arg_name)
24552460

2456-
if a.converter is not None:
2461+
if converter is not None:
24572462
lines.append(
24582463
fmt_setter_with_converter(
24592464
attr_name, arg_name, has_on_setattr
24602465
)
24612466
)
2462-
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
2463-
a.converter
2464-
)
2467+
names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = converter
24652468
else:
24662469
lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))
24672470

24682471
if a.init is True:
2469-
if a.type is not None and a.converter is None:
2472+
if a.type is not None and converter is None:
24702473
annotations[arg_name] = a.type
2471-
elif a.converter is not None:
2472-
# Try to get the type from the converter.
2473-
t = _AnnotationExtractor(a.converter).get_first_param_type()
2474-
if t:
2475-
annotations[arg_name] = t
2474+
elif converter is not None and converter._first_param_type:
2475+
# Use the type from the converter if present.
2476+
annotations[arg_name] = converter._first_param_type
24762477

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

29862987

2988+
class Converter:
2989+
"""
2990+
Stores a converter callable.
2991+
2992+
Allows for the wrapped converter to take additional arguments.
2993+
2994+
:param Callable converter: A callable that converts a value.
2995+
:param bool takes_self: Pass the partially initialized instance that is
2996+
being initialized as a positional argument. (default: `True`)
2997+
2998+
.. versionadded:: 24.1.0
2999+
"""
3000+
3001+
__slots__ = ("converter", "takes_self", "_first_param_type", "__call__")
3002+
3003+
def __init__(self, converter, *, takes_self=True):
3004+
self.converter = converter
3005+
self.takes_self = takes_self
3006+
3007+
ann = _AnnotationExtractor(converter)
3008+
3009+
self._first_param_type = ann.get_first_param_type()
3010+
3011+
# Defining __call__ as a regular method leads to __annotations__ being
3012+
# overwritten at a class level.
3013+
def __call__(value, inst):
3014+
if not self.takes_self:
3015+
return self.converter(value)
3016+
3017+
return self.converter(value, inst)
3018+
3019+
__call__.__annotations__.update(
3020+
ann.get_annotations_for_converter_callable()
3021+
)
3022+
self.__call__ = __call__
3023+
3024+
def __getstate__(self):
3025+
"""
3026+
Return a dict containing only converter and takes_self -- the rest gets
3027+
computed when loading.
3028+
"""
3029+
return {"converter": self.converter, "takes_self": self.takes_self}
3030+
3031+
def __setstate__(self, state):
3032+
"""
3033+
Load instance from state.
3034+
"""
3035+
self.__init__(**state)
3036+
3037+
3038+
_f = [
3039+
Attribute(
3040+
name=name,
3041+
default=NOTHING,
3042+
validator=None,
3043+
repr=True,
3044+
cmp=None,
3045+
eq=True,
3046+
order=False,
3047+
hash=True,
3048+
init=True,
3049+
inherited=False,
3050+
)
3051+
for name in ("converter", "takes_self")
3052+
]
3053+
3054+
Converter = _add_hash(
3055+
_add_eq(_add_repr(Converter, attrs=_f), attrs=_f), attrs=_f
3056+
)
3057+
3058+
29873059
def make_class(
29883060
name, attrs, bases=(object,), class_body=None, **attributes_arguments
29893061
):
@@ -3116,16 +3188,19 @@ def pipe(*converters):
31163188
.. versionadded:: 20.1.0
31173189
"""
31183190

3119-
def pipe_converter(val):
3191+
def pipe_converter(val, inst):
31203192
for converter in converters:
3121-
val = converter(val)
3193+
if isinstance(converter, Converter):
3194+
val = converter(val, inst)
3195+
else:
3196+
val = converter(val)
31223197

31233198
return val
31243199

31253200
if not converters:
31263201
# If the converter list is empty, pipe_converter is the identity.
31273202
A = typing.TypeVar("A")
3128-
pipe_converter.__annotations__ = {"val": A, "return": A}
3203+
pipe_converter.__annotations__.update({"val": A, "return": A})
31293204
else:
31303205
# Get parameter type from first converter.
31313206
t = _AnnotationExtractor(converters[0]).get_first_param_type()
@@ -3137,4 +3212,4 @@ def pipe_converter(val):
31373212
if rt:
31383213
pipe_converter.__annotations__["return"] = rt
31393214

3140-
return pipe_converter
3215+
return Converter(pipe_converter, takes_self=True)

src/attrs/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
NOTHING,
55
Attribute,
66
AttrsInstance,
7+
Converter,
78
Factory,
89
_make_getattr,
910
assoc,
@@ -42,6 +43,7 @@
4243
"Attribute",
4344
"AttrsInstance",
4445
"cmp_using",
46+
"Converter",
4547
"converters",
4648
"define",
4749
"evolve",

0 commit comments

Comments
 (0)