Skip to content

Commit 26801a0

Browse files
author
Scott Sanderson
committed
ENH: Assert that static/class methods match.
1 parent 6f0dfcf commit 26801a0

File tree

4 files changed

+220
-19
lines changed

4 files changed

+220
-19
lines changed

interface/functional.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ def not_f(*args, **kwargs):
1111
return not_f
1212

1313

14+
def keyfilter(f, d):
15+
return {k: v for k, v in d.items() if f(k)}
16+
17+
1418
def valfilter(f, d):
1519
return {k: v for k, v in d.items() if f(v)}
1620

interface/interface.py

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from textwrap import dedent
99
from weakref import WeakKeyDictionary
1010

11+
from .functional import complement, keyfilter
1112
from .typecheck import compatible
13+
from .typed_signature import TypedSignature
1214
from .utils import is_a, unique
1315

1416
first = itemgetter(0)
@@ -21,19 +23,50 @@ class IncompleteImplementation(TypeError):
2123
"""
2224

2325

26+
CLASS_ATTRIBUTE_WHITELIST = frozenset([
27+
'__doc__',
28+
'__module__',
29+
'__name__',
30+
'__qualname__',
31+
'__weakref__',
32+
])
33+
34+
is_interface_field_name = complement(CLASS_ATTRIBUTE_WHITELIST.__contains__)
35+
36+
37+
def static_get_type_attr(t, name):
38+
"""
39+
Get a type attribute statically, circumventing the descriptor protocol.
40+
"""
41+
for type_ in t.mro():
42+
try:
43+
return vars(type_)[name]
44+
except KeyError:
45+
pass
46+
raise AttributeError(name)
47+
48+
2449
class InterfaceMeta(type):
2550
"""
2651
Metaclass for interfaces.
2752
28-
Supplies a ``_signatures`` attribute and a ``check_implementation`` method.
53+
Supplies a ``_signatures`` attribute.
2954
"""
3055
def __new__(mcls, name, bases, clsdict):
3156
signatures = {}
32-
for k, v in clsdict.items():
57+
for k, v in keyfilter(is_interface_field_name, clsdict).items():
3358
try:
34-
signatures[k] = inspect.signature(v)
35-
except TypeError:
36-
pass
59+
signatures[k] = TypedSignature(v)
60+
except TypeError as e:
61+
errmsg = (
62+
"Couldn't parse signature for field "
63+
"{iface_name}.{fieldname} of type {attrtype}.".format(
64+
iface_name=name,
65+
fieldname=k,
66+
attrtype=getname(type(v)),
67+
)
68+
)
69+
raise TypeError(errmsg) from e
3770

3871
clsdict['_signatures'] = signatures
3972
return super().__new__(mcls, name, bases, clsdict)
@@ -49,23 +82,33 @@ def _diff_signatures(self, type_):
4982
5083
Returns
5184
-------
52-
missing, mismatched : list[str], dict[str -> signature]
53-
``missing`` is a list of missing method names.
54-
``mismatched`` is a dict mapping method names to incorrect
55-
signatures.
85+
missing, mistyped, mismatched : list[str], dict[str -> type], dict[str -> signature] # noqa
86+
``missing`` is a list of missing interface names.
87+
``mistyped`` is a list mapping names to incorrect types.
88+
``mismatched`` is a dict mapping names to incorrect signatures.
5689
"""
5790
missing = []
91+
mistyped = {}
5892
mismatched = {}
5993
for name, iface_sig in self._signatures.items():
6094
try:
61-
f = getattr(type_, name)
95+
# Don't invoke the descriptor protocol here so that we get
96+
# staticmethod/classmethod/property objects instead of the
97+
# functions they wrap.
98+
f = static_get_type_attr(type_, name)
6299
except AttributeError:
63100
missing.append(name)
64101
continue
65-
impl_sig = inspect.signature(f)
66-
if not compatible(impl_sig, iface_sig):
102+
103+
impl_sig = TypedSignature(f)
104+
105+
if not issubclass(impl_sig.type, iface_sig.type):
106+
mistyped[name] = impl_sig.type
107+
108+
if not compatible(impl_sig.signature, iface_sig.signature):
67109
mismatched[name] = impl_sig
68-
return missing, mismatched
110+
111+
return missing, mistyped, mismatched
69112

70113
def verify(self, type_):
71114
"""
@@ -85,16 +128,16 @@ def verify(self, type_):
85128
-------
86129
None
87130
"""
88-
missing, mismatched = self._diff_signatures(type_)
89-
if not missing and not mismatched:
131+
missing, mistyped, mismatched = self._diff_signatures(type_)
132+
if not any((missing, mistyped, mismatched)):
90133
return
91-
raise self._invalid_implementation(type_, missing, mismatched)
134+
raise self._invalid_implementation(type_, missing, mistyped, mismatched)
92135

93-
def _invalid_implementation(self, t, missing, mismatched):
136+
def _invalid_implementation(self, t, missing, mistyped, mismatched):
94137
"""
95138
Make a TypeError explaining why ``t`` doesn't implement our interface.
96139
"""
97-
assert missing or mismatched, "Implementation wasn't invalid."
140+
assert missing or mistyped or mismatched, "Implementation wasn't invalid."
98141

99142
message = "\nclass {C} failed to implement interface {I}:".format(
100143
C=getname(t),
@@ -111,6 +154,17 @@ def _invalid_implementation(self, t, missing, mismatched):
111154
missing_methods=self._format_missing_methods(missing)
112155
)
113156

157+
if mistyped:
158+
message += dedent(
159+
"""
160+
161+
The following methods of {I} were implemented with incorrect types:
162+
{mismatched_types}"""
163+
).format(
164+
I=getname(self),
165+
mismatched_types=self._format_mismatched_types(mistyped),
166+
)
167+
114168
if mismatched:
115169
message += dedent(
116170
"""
@@ -129,6 +183,17 @@ def _format_missing_methods(self, missing):
129183
for name in missing
130184
]))
131185

186+
def _format_mismatched_types(self, mistyped):
187+
return "\n".join(sorted([
188+
" - {name}: {actual!r} is not a subtype "
189+
"of expected type {expected!r}".format(
190+
name=name,
191+
actual=getname(bad_type),
192+
expected=getname(self._signatures[name].type),
193+
)
194+
for name, bad_type in mistyped.items()
195+
]))
196+
132197
def _format_mismatched_methods(self, mismatched):
133198
return "\n".join(sorted([
134199
" - {name}{actual} != {name}{expected}".format(

interface/tests/test_interface.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,105 @@ def method3(self, a, b, c):
291291

292292
def test_cant_instantiate_interface():
293293

294-
class I(Interface): # pragma: nocover
294+
class I(Interface):
295295
pass
296296

297297
with pytest.raises(TypeError):
298298
I()
299+
300+
301+
def test_reject_non_callable_interface_field():
302+
303+
with pytest.raises(TypeError) as e:
304+
class IFace(Interface):
305+
x = "not allowed"
306+
307+
308+
def test_static_method():
309+
310+
class I(Interface):
311+
@staticmethod
312+
def foo(a, b): # pragma: nocover
313+
pass
314+
315+
class my_staticmethod(staticmethod):
316+
pass
317+
318+
class Impl(implements(I)):
319+
@my_staticmethod # allow staticmethod subclasses
320+
def foo(a, b): # pragma: nocover
321+
pass
322+
323+
with pytest.raises(IncompleteImplementation) as e:
324+
class Impl(implements(I)):
325+
@staticmethod
326+
def foo(self, a, b): # pragma: nocover
327+
pass
328+
329+
expected = dedent(
330+
"""
331+
class Impl failed to implement interface I:
332+
333+
The following methods of I were implemented with invalid signatures:
334+
- foo(self, a, b) != foo(a, b)"""
335+
)
336+
assert expected == str(e.value)
337+
338+
with pytest.raises(IncompleteImplementation) as e:
339+
class Impl(implements(I)):
340+
def foo(a, b): # pragma: nocover
341+
pass
342+
343+
expected = dedent(
344+
"""
345+
class Impl failed to implement interface I:
346+
347+
The following methods of I were implemented with incorrect types:
348+
- foo: 'function' is not a subtype of expected type 'staticmethod'"""
349+
)
350+
assert expected == str(e.value)
351+
352+
353+
def test_class_method():
354+
355+
class I(Interface):
356+
@classmethod
357+
def foo(cls, a, b): # pragma: nocover
358+
pass
359+
360+
class my_classmethod(classmethod):
361+
pass
362+
363+
class Impl(implements(I)):
364+
@my_classmethod
365+
def foo(cls, a, b): # pragma: nocover
366+
pass
367+
368+
with pytest.raises(IncompleteImplementation) as e:
369+
class Impl(implements(I)):
370+
@classmethod
371+
def foo(a, b): # pragma: nocover
372+
pass
373+
374+
expected = dedent(
375+
"""
376+
class Impl failed to implement interface I:
377+
378+
The following methods of I were implemented with invalid signatures:
379+
- foo(a, b) != foo(cls, a, b)"""
380+
)
381+
assert expected == str(e.value)
382+
383+
with pytest.raises(IncompleteImplementation) as e:
384+
class Impl(implements(I)):
385+
def foo(cls, a, b): # pragma: nocover
386+
pass
387+
388+
expected = dedent(
389+
"""
390+
class Impl failed to implement interface I:
391+
392+
The following methods of I were implemented with incorrect types:
393+
- foo: 'function' is not a subtype of expected type 'classmethod'"""
394+
)
395+
assert expected == str(e.value)

interface/typed_signature.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
A subclass of inspect.signature that knows what kind of type it came from.
3+
4+
This is useful for when we care about the distinction between different kinds
5+
of callables, e.g., between methods, classmethods, and staticmethods.
6+
"""
7+
from inspect import signature
8+
9+
10+
class TypedSignature(object):
11+
"""
12+
Wrapper around an inspect.Signature that knows what kind of callable it came from.
13+
14+
Parameters
15+
----------
16+
obj : callable
17+
An object from which to extract a signature and a type.
18+
"""
19+
def __init__(self, obj):
20+
self._type = type(obj)
21+
if isinstance(obj, (classmethod, staticmethod)):
22+
self._signature = signature(obj.__func__)
23+
else:
24+
self._signature = signature(obj)
25+
26+
@property
27+
def signature(self):
28+
return self._signature
29+
30+
@property
31+
def type(self):
32+
return self._type
33+
34+
def __str__(self):
35+
return str(self._signature)

0 commit comments

Comments
 (0)