From 9bc8b10758bf0599b28ae8b9df3838c97a4e45a2 Mon Sep 17 00:00:00 2001 From: Scott Sanderson Date: Tue, 15 May 2018 16:01:21 -0400 Subject: [PATCH] ENH: Enforce that sync-ness matches. --- interface/compat.py | 28 +++++++++++++ interface/interface.py | 6 ++- interface/tests/_py3_interface_tests.py | 55 +++++++++++++++++++++++++ interface/tests/_py3_typecheck_tests.py | 48 +++++++++++++++++---- interface/tests/test_interface.py | 4 ++ interface/tests/test_typecheck.py | 39 ++++++++++++------ interface/typecheck.py | 15 ++++--- interface/typed_signature.py | 10 ++++- 8 files changed, 174 insertions(+), 31 deletions(-) create mode 100644 interface/tests/_py3_interface_tests.py diff --git a/interface/compat.py b/interface/compat.py index bcf7f29..5b4dc78 100644 --- a/interface/compat.py +++ b/interface/compat.py @@ -45,10 +45,24 @@ def _is_wrapper(f): memo.add(id_func) return func + def is_coroutine(f): + return False + else: # pragma: nocover-py2 from inspect import signature, Parameter, unwrap + try: + from inspect import CO_COROUTINE, CO_ITERABLE_COROUTINE + except ImportError: + # If we don't have these flags, there aren't any coroutines yet in + # Python 3. + def is_coroutine(f): + return False + else: + def is_coroutine(f): + return f.__code__.co_flags & (CO_COROUTINE | CO_ITERABLE_COROUTINE) + wraps = functools.wraps exec("def raise_from(e, from_):" # pragma: nocover @@ -96,3 +110,17 @@ class metaclass(meta): def __new__(cls, name, this_bases, d): return meta(name, bases, d) return type.__new__(metaclass, 'temporary_class', (), {}) + + +__all__ = [ + 'PY2', + 'PY3', + 'Parameter', + 'is_coroutine', + 'signature', + 'unwrap', + 'version_info', + 'viewkeys', + 'with_metaclass', + 'wraps', +] diff --git a/interface/interface.py b/interface/interface.py index ca00533..bb23cb5 100644 --- a/interface/interface.py +++ b/interface/interface.py @@ -147,7 +147,7 @@ def _diff_signatures(self, type_): if not issubclass(impl_sig.type, iface_sig.type): mistyped[name] = impl_sig.type - if not compatible(impl_sig.signature, iface_sig.signature): + if not compatible(impl_sig, iface_sig): mismatched[name] = impl_sig return missing, mistyped, mismatched @@ -249,10 +249,12 @@ def _format_mismatched_types(self, mistyped): def _format_mismatched_methods(self, mismatched): return "\n".join(sorted([ - " - {name}{actual} != {name}{expected}".format( + " - {actual_async}{name}{actual} != {expected_async}{name}{expected}".format( name=name, actual=bad_sig, + actual_async="async " if bad_sig.is_coroutine else "", expected=self._signatures[name], + expected_async="async " if self._signatures[name].is_coroutine else "", ) for name, bad_sig in mismatched.items() ])) diff --git a/interface/tests/_py3_interface_tests.py b/interface/tests/_py3_interface_tests.py new file mode 100644 index 0000000..a36b591 --- /dev/null +++ b/interface/tests/_py3_interface_tests.py @@ -0,0 +1,55 @@ +from textwrap import dedent + +import pytest + +from ..interface import implements, Interface, InvalidImplementation + + +def test_async_interface_sync_impl(): + + class AsyncInterface(Interface): + async def foo(self, a, b): # pragma: nocover + pass + + # This should pass. + class AsyncImpl(implements(AsyncInterface)): + async def foo(self, a, b): # pragma: nocover + pass + + # This should barf because foo isn't async. + with pytest.raises(InvalidImplementation) as e: + class SyncImpl(implements(AsyncInterface)): + def foo(self, a, b): # pragma: nocover + pass + + actual_message = str(e.value) + expected_message = dedent( + """ + class SyncImpl failed to implement interface AsyncInterface: + + The following methods of AsyncInterface were implemented with invalid signatures: + - foo(self, a, b) != async foo(self, a, b)""" + ) + assert actual_message == expected_message + + +def test_sync_interface_async_impl(): + + class SyncInterface(Interface): + def foo(self, a, b): # pragma: nocover + pass + + with pytest.raises(InvalidImplementation) as e: + class AsyncImpl(implements(SyncInterface)): + async def foo(self, a, b): # pragma: nocover + pass + + actual_message = str(e.value) + expected_message = dedent( + """ + class AsyncImpl failed to implement interface SyncInterface: + + The following methods of SyncInterface were implemented with invalid signatures: + - async foo(self, a, b) != foo(self, a, b)""" + ) + assert actual_message == expected_message diff --git a/interface/tests/_py3_typecheck_tests.py b/interface/tests/_py3_typecheck_tests.py index 57bfa3a..368bc81 100644 --- a/interface/tests/_py3_typecheck_tests.py +++ b/interface/tests/_py3_typecheck_tests.py @@ -1,15 +1,16 @@ -from inspect import signature +import types from ..typecheck import compatible +from ..typed_signature import TypedSignature def test_allow_new_params_with_defaults_with_kwonly(): - @signature + @TypedSignature def iface(a, b, c): # pragma: nocover pass - @signature + @TypedSignature def impl(a, b, c, d=3, e=5, *, f=5): # pragma: nocover pass @@ -19,11 +20,11 @@ def impl(a, b, c, d=3, e=5, *, f=5): # pragma: nocover def test_allow_reorder_kwonlys(): - @signature + @TypedSignature def foo(a, b, c, *, d, e, f): # pragma: nocover pass - @signature + @TypedSignature def bar(a, b, c, *, f, d, e): # pragma: nocover pass @@ -33,11 +34,11 @@ def bar(a, b, c, *, f, d, e): # pragma: nocover def test_allow_default_changes(): - @signature + @TypedSignature def foo(a, b, c=3, *, d=1, e, f): # pragma: nocover pass - @signature + @TypedSignature def bar(a, b, c=5, *, f, e, d=12): # pragma: nocover pass @@ -47,13 +48,42 @@ def bar(a, b, c=5, *, f, e, d=12): # pragma: nocover def test_disallow_kwonly_to_positional(): - @signature + @TypedSignature def foo(a, *, b): # pragma: nocover pass - @signature + @TypedSignature def bar(a, b): # pragma: nocover pass assert not compatible(foo, bar) assert not compatible(bar, foo) + + +def test_async_def_functions_are_coroutines(): + + @TypedSignature + async def foo(a, b): # pragma: nocover + pass + + @TypedSignature + def bar(a, b): # pragma: nocover + pass + + assert foo.is_coroutine + assert not compatible(foo, bar) + + +def test_types_dot_coroutines_are_coroutines(): + + @TypedSignature + @types.coroutine + def foo(a, b): # pragma: nocover + yield from foo() + + @TypedSignature + def bar(a, b): # pragma: nocover + pass + + assert foo.is_coroutine + assert not compatible(foo, bar) diff --git a/interface/tests/test_interface.py b/interface/tests/test_interface.py index 97fa785..31b1a6e 100644 --- a/interface/tests/test_interface.py +++ b/interface/tests/test_interface.py @@ -874,3 +874,7 @@ class BadImpl failed to implement interface HasMagicMethodsInterface: - __getitem__(self, key)""" ) assert actual_message == expected_message + + +if PY3: + from ._py3_interface_tests import * # noqa diff --git a/interface/tests/test_typecheck.py b/interface/tests/test_typecheck.py index 25be7d2..09058ec 100644 --- a/interface/tests/test_typecheck.py +++ b/interface/tests/test_typecheck.py @@ -1,17 +1,17 @@ -from ..compat import PY3, signature +from ..compat import PY3 from ..typecheck import compatible from ..typed_signature import TypedSignature def test_compatible_when_equal(): - @signature + @TypedSignature def foo(a, b, c): # pragma: nocover pass assert compatible(foo, foo) - @signature + @TypedSignature def bar(): # pragma: nocover pass @@ -20,11 +20,11 @@ def bar(): # pragma: nocover def test_disallow_new_or_missing_positionals(): - @signature + @TypedSignature def foo(a, b): # pragma: nocover pass - @signature + @TypedSignature def bar(a): # pragma: nocover pass @@ -34,11 +34,11 @@ def bar(a): # pragma: nocover def test_disallow_remove_defaults(): - @signature + @TypedSignature def iface(a, b=3): # pragma: nocover pass - @signature + @TypedSignature def impl(a, b): # pragma: nocover pass @@ -47,11 +47,11 @@ def impl(a, b): # pragma: nocover def test_disallow_reorder_positionals(): - @signature + @TypedSignature def foo(a, b): # pragma: nocover pass - @signature + @TypedSignature def bar(b, a): # pragma: nocover pass @@ -61,11 +61,11 @@ def bar(b, a): # pragma: nocover def test_allow_new_params_with_defaults_no_kwonly(): - @signature + @TypedSignature def iface(a, b, c): # pragma: nocover pass - @signature + @TypedSignature def impl(a, b, c, d=3, e=5, f=5): # pragma: nocover pass @@ -78,5 +78,20 @@ def test_first_argument_name(): assert TypedSignature(lambda: 0).first_argument_name is None +def test_regular_functions_arent_coroutines(): + + @TypedSignature + def foo(a, b, c): # pragma: nocover + pass + + assert not foo.is_coroutine + + @TypedSignature + def bar(a, b, c): # pragma: nocover + yield 1 + + assert not bar.is_coroutine + + if PY3: # pragma: nocover - from ._py3_typecheck_tests import * + from ._py3_typecheck_tests import * # noqa diff --git a/interface/typecheck.py b/interface/typecheck.py index fa2052e..920402e 100644 --- a/interface/typecheck.py +++ b/interface/typecheck.py @@ -13,9 +13,9 @@ def compatible(impl_sig, iface_sig): Parameters ---------- - impl_sig : inspect.Signature + impl_sig : interface.typed_signature.TypedSignature The signature of the implementation function. - iface_sig : inspect.Signature + iface_sig : interface.typed_signature.TypedSignature The signature of the interface function. In general, an implementation is compatible with an interface if any valid @@ -37,14 +37,17 @@ def compatible(impl_sig, iface_sig): b. The return type of an implementation may be annotated with a **subclass** of the type specified by the interface. """ + real_impl_sig = impl_sig.signature + real_iface_sig = iface_sig.signature return all([ + impl_sig.is_coroutine == iface_sig.is_coroutine, positionals_compatible( - takewhile(is_positional, impl_sig.parameters.values()), - takewhile(is_positional, iface_sig.parameters.values()), + takewhile(is_positional, real_impl_sig.parameters.values()), + takewhile(is_positional, real_iface_sig.parameters.values()), ), keywords_compatible( - valfilter(complement(is_positional), impl_sig.parameters), - valfilter(complement(is_positional), iface_sig.parameters), + valfilter(complement(is_positional), real_impl_sig.parameters), + valfilter(complement(is_positional), real_iface_sig.parameters), ), ]) diff --git a/interface/typed_signature.py b/interface/typed_signature.py index 3107e9b..f42d8b2 100644 --- a/interface/typed_signature.py +++ b/interface/typed_signature.py @@ -6,7 +6,7 @@ """ import types -from .compat import signature, unwrap +from .compat import is_coroutine, signature, unwrap from .default import default @@ -24,12 +24,18 @@ def __init__(self, obj): if self._type is default: self._type = type(obj.implementation) - self._signature = signature(extract_func(obj)) + func = extract_func(obj) + self._signature = signature(func) + self._is_coroutine = is_coroutine(func) @property def signature(self): return self._signature + @property + def is_coroutine(self): + return self._is_coroutine + @property def first_argument_name(self): try: