Skip to content

Commit 7c66455

Browse files
author
Scott Sanderson
authored
Merge pull request #1 from ssanderson/allow-compatible-extensions
ENH: Add support for interface extensions.
2 parents 8cd72cf + 7f15fd4 commit 7c66455

File tree

8 files changed

+292
-16
lines changed

8 files changed

+292
-16
lines changed

interface/functional.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""
2+
functional
3+
----------
4+
Functional programming utilities.
5+
"""
6+
7+
8+
def complement(f):
9+
def not_f(*args, **kwargs):
10+
return not f(*args, **kwargs)
11+
return not_f
12+
13+
14+
def valfilter(f, d):
15+
return {k: v for k, v in d.items() if f(v)}
16+
17+
18+
def dzip(left, right):
19+
return {k: (left.get(k), right.get(k)) for k in left.keys() & right.keys()}

interface/interface.py

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

11+
from .typecheck import compatible
1112
from .utils import is_a, unique
1213

1314
first = itemgetter(0)
@@ -20,15 +21,6 @@ class IncompleteImplementation(TypeError):
2021
"""
2122

2223

23-
def compatible(meth_sig, iface_sig):
24-
"""
25-
Check if ``method``'s signature is compatible with ``signature``.
26-
"""
27-
# TODO: Allow method to provide defaults and optional extensions to
28-
# ``signature``.
29-
return meth_sig == iface_sig
30-
31-
3224
class InterfaceMeta(type):
3325
"""
3426
Metaclass for interfaces.
@@ -70,9 +62,9 @@ def _diff_signatures(self, type_):
7062
except AttributeError:
7163
missing.append(name)
7264
continue
73-
f_sig = inspect.signature(f)
74-
if not compatible(f_sig, iface_sig):
75-
mismatched[name] = f_sig
65+
impl_sig = inspect.signature(f)
66+
if not compatible(impl_sig, iface_sig):
67+
mismatched[name] = impl_sig
7668
return missing, mismatched
7769

7870
def verify(self, type_):
@@ -120,10 +112,11 @@ def _invalid_implementation(self, t, missing, mismatched):
120112
)
121113

122114
if mismatched:
123-
message += (
124-
"\n\nThe following methods of {I} were implemented with invalid"
125-
" signatures:\n"
126-
"{mismatched_methods}"
115+
message += dedent(
116+
"""
117+
118+
The following methods of {I} were implemented with invalid signatures:
119+
{mismatched_methods}"""
127120
).format(
128121
I=getname(self),
129122
mismatched_methods=self._format_mismatched_methods(mismatched),

interface/tests/test_typecheck.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from inspect import signature
2+
3+
from ..typecheck import compatible
4+
5+
6+
def test_compatible_when_equal():
7+
8+
@signature
9+
def foo(a, b, c): # pragma: nocover
10+
pass
11+
12+
assert compatible(foo, foo)
13+
14+
@signature
15+
def bar(): # pragma: nocover
16+
pass
17+
18+
assert compatible(bar, bar)
19+
20+
21+
def test_allow_new_params_with_defaults():
22+
23+
@signature
24+
def iface(a, b, c): # pragma: nocover
25+
pass
26+
27+
@signature
28+
def impl(a, b, c, d=3, e=5, *, f=5): # pragma: nocover
29+
pass
30+
31+
assert compatible(impl, iface)
32+
assert not compatible(iface, impl)
33+
34+
35+
def test_allow_reorder_kwonlys():
36+
37+
@signature
38+
def foo(a, b, c, *, d, e, f): # pragma: nocover
39+
pass
40+
41+
@signature
42+
def bar(a, b, c, *, f, d, e): # pragma: nocover
43+
pass
44+
45+
assert compatible(foo, bar)
46+
assert compatible(bar, foo)
47+
48+
49+
def test_allow_default_changes():
50+
51+
@signature
52+
def foo(a, b, c=3, *, d=1, e, f): # pragma: nocover
53+
pass
54+
55+
@signature
56+
def bar(a, b, c=5, *, f, e, d=12): # pragma: nocover
57+
pass
58+
59+
assert compatible(foo, bar)
60+
assert compatible(bar, foo)
61+
62+
63+
def test_disallow_new_or_missing_positionals():
64+
65+
@signature
66+
def foo(a, b): # pragma: nocover
67+
pass
68+
69+
@signature
70+
def bar(a): # pragma: nocover
71+
pass
72+
73+
assert not compatible(foo, bar)
74+
assert not compatible(bar, foo)
75+
76+
77+
def test_disallow_remove_defaults():
78+
79+
@signature
80+
def iface(a, b=3): # pragma: nocover
81+
pass
82+
83+
@signature
84+
def impl(a, b): # pragma: nocover
85+
pass
86+
87+
assert not compatible(impl, iface)
88+
89+
90+
def test_disallow_reorder_positionals():
91+
92+
@signature
93+
def foo(a, b): # pragma: nocover
94+
pass
95+
96+
@signature
97+
def bar(b, a): # pragma: nocover
98+
pass
99+
100+
assert not compatible(foo, bar)
101+
assert not compatible(bar, foo)
102+
103+
104+
def test_disallow_kwonly_to_positional():
105+
106+
@signature
107+
def foo(a, *, b): # pragma: nocover
108+
pass
109+
110+
@signature
111+
def bar(a, b): # pragma: nocover
112+
pass
113+
114+
assert not compatible(foo, bar)
115+
assert not compatible(bar, foo)

interface/tests/test_utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
3+
from ..utils import is_a, unique
4+
5+
6+
def test_unique():
7+
assert list(unique(iter([1, 3, 1, 2, 3]))) == [1, 3, 2]
8+
9+
10+
def test_is_a():
11+
assert is_a(int)(5)
12+
assert not is_a(str)(5)

interface/typecheck.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""
2+
Utilities for typed interfaces.
3+
"""
4+
from inspect import Parameter
5+
from itertools import dropwhile, starmap, takewhile, zip_longest
6+
from operator import contains
7+
8+
from .functional import complement, dzip, valfilter
9+
10+
11+
def compatible(impl_sig, iface_sig):
12+
"""
13+
Check whether ``impl_sig`` is compatible with ``iface_sig``.
14+
15+
In general, an implementation is compatible with an interface if any valid
16+
way of passing parameters to the interface method is also valid for the
17+
implementation.
18+
19+
The following differences are allowed between an implementation and its
20+
interface:
21+
22+
1. An implementation may add new arguments to an interface iff:
23+
a. All new arguments have default values.
24+
b. All new arguments accepted positionally (i.e. all non-keyword-only
25+
arguments) occur after any arguments declared by the interface.
26+
c. Keyword-only arguments may be reordered by the implementation.
27+
28+
2. For type-annotated interfaces, type annotations my differ as follows:
29+
a. Arguments to implementations of an interface may be annotated with
30+
a **superclass** of the type specified by the interface.
31+
b. The return type of an implementation may be annotated with a
32+
**subclass** of the type specified by the interface.
33+
"""
34+
return all([
35+
positionals_compatible(
36+
takewhile(is_positional, impl_sig.parameters.values()),
37+
takewhile(is_positional, iface_sig.parameters.values()),
38+
),
39+
keywords_compatible(
40+
valfilter(complement(is_positional), impl_sig.parameters),
41+
valfilter(complement(is_positional), iface_sig.parameters),
42+
),
43+
])
44+
45+
46+
_POSITIONALS = frozenset([
47+
Parameter.POSITIONAL_ONLY,
48+
Parameter.POSITIONAL_OR_KEYWORD,
49+
])
50+
51+
52+
def is_positional(arg):
53+
return arg.kind in _POSITIONALS
54+
55+
56+
def has_default(arg):
57+
"""
58+
Does ``arg`` provide a default?
59+
"""
60+
return arg.default is not Parameter.empty
61+
62+
63+
def params_compatible(impl, iface):
64+
65+
if impl is None:
66+
return False
67+
68+
if iface is None:
69+
return has_default(impl)
70+
71+
return (
72+
impl.name == iface.name and
73+
impl.kind == iface.kind and
74+
has_default(impl) == has_default(iface) and
75+
annotations_compatible(impl, iface)
76+
)
77+
78+
79+
def positionals_compatible(impl_positionals, iface_positionals):
80+
return all(
81+
starmap(params_compatible, zip_longest(impl_positionals, iface_positionals))
82+
)
83+
84+
85+
def keywords_compatible(impl_keywords, iface_keywords):
86+
return all(
87+
starmap(params_compatible, dzip(impl_keywords, iface_keywords).values())
88+
)
89+
90+
91+
def annotations_compatible(impl, iface):
92+
"""
93+
Check whether the type annotations of an implementation are compatible with
94+
the annotations of the interface it implements.
95+
"""
96+
return impl.annotation == iface.annotation

interface/utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
Miscellaneous utilities.
3+
"""
4+
5+
6+
def unique(g):
7+
"""
8+
Yield values yielded by ``g``, removing any duplicates.
9+
10+
Example
11+
-------
12+
>>> list(unique(iter([1, 3, 1, 2, 3])))
13+
[1, 3, 2]
14+
"""
15+
yielded = set()
16+
for value in g:
17+
if value not in yielded:
18+
yield value
19+
yielded.add(value)
20+
21+
22+
def is_a(t):
23+
"""
24+
Partially-applied, flipped isinstance.
25+
26+
>>> is_a(int)(5)
27+
True
28+
>>> is_a(str)(5)
29+
False
30+
"""
31+
return lambda v: isinstance(v, t)

setup.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ def extras_require():
1919
],
2020
}
2121

22+
def install_requires():
23+
if sys.version_info[:2] < (3, 5):
24+
return ["typing>=3.5.2"]
25+
return []
26+
2227

2328
setup(
2429
name='interface',
@@ -39,5 +44,6 @@ def extras_require():
3944
'Topic :: Software Development :: Pre-processors',
4045
],
4146
url='https://github.com/ssanderson/interface',
47+
install_requires=install_requires(),
4248
extras_require=extras_require(),
4349
)

tox.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
envlist=py{34,35}
33
skip_missing_interpreters=true
44

5+
[pep8]
6+
max-line-length = 90
7+
58
[testenv]
69
commands=
710
pip install -e .[test]
811
py.test --cov-fail-under 100
912

1013
[pytest]
14+
pep8maxlinelength = 90
1115
addopts = --pep8 --cov interface --cov-report term-missing --cov-report html
1216
testpaths = interface

0 commit comments

Comments
 (0)