Skip to content

Commit 656ce11

Browse files
authored
Deduplicate annotation extraction (#912)
* Deduplicate annotation extraction * slots baby * Add docstrings * Ensure empty pipe returns same object
1 parent 9f09614 commit 656ce11

4 files changed

Lines changed: 93 additions & 61 deletions

File tree

src/attr/_compat.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,26 @@ def just_warn(*args, **kw): # pragma: no cover
112112
consequences of not setting the cell on Python 2.
113113
"""
114114

115+
class _AnnotationExtractor:
116+
"""
117+
Always return None, allows to keep ``if PY2``s from code.
118+
"""
119+
120+
__slots__ = ["sig"]
121+
sig = None
122+
123+
def __init__(self, callable):
124+
pass
125+
126+
def get_first_param_type(self):
127+
return None
128+
129+
def get_return_type(self):
130+
return None
131+
115132
else: # Python 3 and later.
133+
import inspect
134+
116135
from collections.abc import Mapping, Sequence # noqa
117136

118137
def just_warn(*args, **kw):
@@ -141,6 +160,45 @@ def iteritems(d):
141160
def metadata_proxy(d):
142161
return types.MappingProxyType(dict(d))
143162

163+
class _AnnotationExtractor:
164+
"""
165+
Extract type annotations from a callable, returning None whenever there
166+
is none.
167+
"""
168+
169+
__slots__ = ["sig"]
170+
171+
def __init__(self, callable):
172+
try:
173+
self.sig = inspect.signature(callable)
174+
except (ValueError, TypeError): # inspect failed
175+
self.sig = None
176+
177+
def get_first_param_type(self):
178+
"""
179+
Return the type annotation of the first argument if it's not empty.
180+
"""
181+
if not self.sig:
182+
return None
183+
184+
params = list(self.sig.parameters.values())
185+
if params and params[0].annotation is not inspect.Parameter.empty:
186+
return params[0].annotation
187+
188+
return None
189+
190+
def get_return_type(self):
191+
"""
192+
Return the return type if it's not empty.
193+
"""
194+
if (
195+
self.sig
196+
and self.sig.return_annotation is not inspect.Signature.empty
197+
):
198+
return self.sig.return_annotation
199+
200+
return None
201+
144202

145203
def make_set_closure_cell():
146204
"""Return a function of two arguments (cell, value) which sets

src/attr/_make.py

Lines changed: 17 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import absolute_import, division, print_function
44

55
import copy
6-
import inspect
76
import linecache
87
import sys
98
import warnings
@@ -18,6 +17,7 @@
1817
PY2,
1918
PY310,
2019
PYPY,
20+
_AnnotationExtractor,
2121
isclass,
2222
iteritems,
2323
metadata_proxy,
@@ -2501,21 +2501,11 @@ def fmt_setter_with_converter(
25012501
if a.init is True:
25022502
if a.type is not None and a.converter is None:
25032503
annotations[arg_name] = a.type
2504-
elif a.converter is not None and not PY2:
2504+
elif a.converter is not None:
25052505
# Try to get the type from the converter.
2506-
sig = None
2507-
try:
2508-
sig = inspect.signature(a.converter)
2509-
except (ValueError, TypeError): # inspect failed
2510-
pass
2511-
if sig:
2512-
sig_params = list(sig.parameters.values())
2513-
if (
2514-
sig_params
2515-
and sig_params[0].annotation
2516-
is not inspect.Parameter.empty
2517-
):
2518-
annotations[arg_name] = sig_params[0].annotation
2506+
t = _AnnotationExtractor(a.converter).get_first_param_type()
2507+
if t:
2508+
annotations[arg_name] = t
25192509

25202510
if attrs_to_validate: # we can skip this if there are no validators.
25212511
names_for_globals["_config"] = _config
@@ -3135,36 +3125,20 @@ def pipe_converter(val):
31353125

31363126
return val
31373127

3138-
if not PY2:
3139-
if not converters:
3128+
if not converters:
3129+
if not PY2:
31403130
# If the converter list is empty, pipe_converter is the identity.
31413131
A = typing.TypeVar("A")
31423132
pipe_converter.__annotations__ = {"val": A, "return": A}
3143-
else:
3144-
# Get parameter type.
3145-
sig = None
3146-
try:
3147-
sig = inspect.signature(converters[0])
3148-
except (ValueError, TypeError): # inspect failed
3149-
pass
3150-
if sig:
3151-
params = list(sig.parameters.values())
3152-
if (
3153-
params
3154-
and params[0].annotation is not inspect.Parameter.empty
3155-
):
3156-
pipe_converter.__annotations__["val"] = params[
3157-
0
3158-
].annotation
3159-
# Get return type.
3160-
sig = None
3161-
try:
3162-
sig = inspect.signature(converters[-1])
3163-
except (ValueError, TypeError): # inspect failed
3164-
pass
3165-
if sig and sig.return_annotation is not inspect.Signature().empty:
3166-
pipe_converter.__annotations__[
3167-
"return"
3168-
] = sig.return_annotation
3133+
else:
3134+
# Get parameter type from first converter.
3135+
t = _AnnotationExtractor(converters[0]).get_first_param_type()
3136+
if t:
3137+
pipe_converter.__annotations__["val"] = t
3138+
3139+
# Get return type from last converter.
3140+
rt = _AnnotationExtractor(converters[-1]).get_return_type()
3141+
if rt:
3142+
pipe_converter.__annotations__["return"] = rt
31693143

31703144
return pipe_converter

src/attr/converters.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66

77
from __future__ import absolute_import, division, print_function
88

9-
from ._compat import PY2
9+
from ._compat import PY2, _AnnotationExtractor
1010
from ._make import NOTHING, Factory, pipe
1111

1212

1313
if not PY2:
14-
import inspect
1514
import typing
1615

1716

@@ -42,22 +41,15 @@ def optional_converter(val):
4241
return None
4342
return converter(val)
4443

45-
if not PY2:
46-
sig = None
47-
try:
48-
sig = inspect.signature(converter)
49-
except (ValueError, TypeError): # inspect failed
50-
pass
51-
if sig:
52-
params = list(sig.parameters.values())
53-
if params and params[0].annotation is not inspect.Parameter.empty:
54-
optional_converter.__annotations__["val"] = typing.Optional[
55-
params[0].annotation
56-
]
57-
if sig.return_annotation is not inspect.Signature.empty:
58-
optional_converter.__annotations__["return"] = typing.Optional[
59-
sig.return_annotation
60-
]
44+
xtr = _AnnotationExtractor(converter)
45+
46+
t = xtr.get_first_param_type()
47+
if t:
48+
optional_converter.__annotations__["val"] = typing.Optional[t]
49+
50+
rt = xtr.get_return_type()
51+
if rt:
52+
optional_converter.__annotations__["return"] = typing.Optional[rt]
6153

6254
return optional_converter
6355

tests/test_converters.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ class C(object):
137137
c = C()
138138
assert True is c.a1 is c.a2
139139

140+
def test_empty(self):
141+
"""
142+
Empty pipe returns same value.
143+
"""
144+
o = object()
145+
146+
assert o is pipe()(o)
147+
140148

141149
class TestToBool(object):
142150
def test_unhashable(self):

0 commit comments

Comments
 (0)