From 87faa0bdd022d3d11ff6f33696ba8d1d37982181 Mon Sep 17 00:00:00 2001 From: Scott Sanderson Date: Fri, 1 May 2020 10:43:53 -0400 Subject: [PATCH 1/3] ENH: Add subinterfaces. Add support for creating sub-interfaces by subclassing from an interface. A sub interface inherits all the interface members of its parents, and may add new interface items. It can also override parent members with new signatures, provided that any implementation of the child interface is also a valid implementation of the parent interface. --- interface/functional.py | 13 +++ interface/interface.py | 73 +++++++++++++--- interface/tests/_py3_typecheck_tests.py | 19 ++--- interface/tests/test_interface.py | 107 ++++++++++++++++++++++++ interface/tests/test_typecheck.py | 57 ++++++++++--- interface/tests/test_utils.py | 9 ++ interface/typecheck.py | 7 +- interface/typed_signature.py | 5 ++ 8 files changed, 256 insertions(+), 34 deletions(-) diff --git a/interface/functional.py b/interface/functional.py index 347d216..ec23d8b 100644 --- a/interface/functional.py +++ b/interface/functional.py @@ -46,3 +46,16 @@ def sliding_window(iterable, n): for item in it: items.append(item) yield tuple(items) + + +def merge(dicts): + dicts = list(dicts) + if len(dicts) == 0: + return {} + elif len(dicts) == 1: + return dicts[0] + else: + out = dicts[0].copy() + for other in dicts[1:]: + out.update(other) + return out diff --git a/interface/interface.py b/interface/interface.py index 6b31906..ed9329f 100644 --- a/interface/interface.py +++ b/interface/interface.py @@ -6,7 +6,7 @@ from .compat import raise_from, with_metaclass from .default import default, warn_if_defaults_use_non_interface_members from .formatting import bulleted_list -from .functional import complement, keyfilter, valfilter +from .functional import complement, keyfilter, merge, valfilter from .typecheck import compatible from .typed_signature import TypedSignature from .utils import is_a, unique @@ -21,8 +21,22 @@ class InvalidImplementation(TypeError): """ +class InvalidSubInterface(TypeError): + """ + Raised when on attempt to define a subclass of an interface that's not + compatible with the parent definition. + """ + + CLASS_ATTRIBUTE_WHITELIST = frozenset( - ["__doc__", "__module__", "__name__", "__qualname__", "__weakref__"] + [ + "__doc__", + "__module__", + "__name__", + "__qualname__", + "__weakref__", + "_INTERFACE_IGNORE_MEMBERS", + ] ) is_interface_field_name = complement(CLASS_ATTRIBUTE_WHITELIST.__contains__) @@ -72,6 +86,14 @@ def _conflicting_defaults(typename, conflicts): return InvalidImplementation(message) +def _merge_parent_signatures(bases): + return merge(filter(None, (getattr(b, "_signatures") for b in bases))) + + +def _merge_parent_defaults(bases): + return merge(filter(None, (getattr(b, "_defaults") for b in bases))) + + class InterfaceMeta(type): """ Metaclass for interfaces. @@ -80,22 +102,43 @@ class InterfaceMeta(type): """ def __new__(mcls, name, bases, clsdict): - signatures = {} - defaults = {} - for k, v in keyfilter(is_interface_field_name, clsdict).items(): + signatures = _merge_parent_signatures(bases) + defaults = _merge_parent_defaults(bases) + ignored = clsdict.get("_INTERFACE_IGNORE_MEMBERS", set()) + + for field, v in keyfilter(is_interface_field_name, clsdict).items(): + if field in ignored: + continue + try: - signatures[k] = TypedSignature(v) + signature = TypedSignature(v) except TypeError as e: errmsg = ( "Couldn't parse signature for field " "{iface_name}.{fieldname} of type {attrtype}.".format( - iface_name=name, fieldname=k, attrtype=getname(type(v)), + iface_name=name, fieldname=field, attrtype=getname(type(v)), ) ) raise_from(TypeError(errmsg), e) + # If we already have a signature for this field from a parent, then + # our new signature must be a subtype of the parent signature, so + # that any valid call to the new signature must also be a valid + # call to the parent signature. + if field in signatures and not compatible(signature, signatures[field]): + conflicted = signatures[field] + raise InvalidSubInterface( + "\nInterface field {new}.{field} conflicts with inherited field of " + "the same name.\n" + " - {field}{new_sig} != {field}{old_sig}".format( + new=name, field=field, new_sig=signature, old_sig=conflicted, + ) + ) + else: + signatures[field] = signature + if isinstance(v, default): - defaults[k] = v + defaults[field] = v warn_if_defaults_use_non_interface_members( name, defaults, set(signatures.keys()) @@ -139,7 +182,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 @@ -258,6 +301,9 @@ def _format_mismatched_methods(self, mismatched): ) +empty_set = frozenset([]) + + class Interface(with_metaclass(InterfaceMeta)): """ Base class for interface definitions. @@ -313,6 +359,10 @@ def delete(self, key): :func:`implements` """ + # Don't consider these members part of the interface definition for + # children of `Interface`. + _INTERFACE_IGNORE_MEMBERS = {"__new__", "from_class"} + def __new__(cls, *args, **kwargs): raise TypeError("Can't instantiate interface %s" % getname(cls)) @@ -349,7 +399,10 @@ def from_class(cls, existing_class, subset=None, name=None): ) -empty_set = frozenset([]) +# Signature requirements are inherited, so make sure the base interface doesn't +# require any methods of children. +assert Interface._signatures == {} +assert Interface._defaults == {} class ImplementsMeta(type): diff --git a/interface/tests/_py3_typecheck_tests.py b/interface/tests/_py3_typecheck_tests.py index 1f0d5a3..d5ed9f4 100644 --- a/interface/tests/_py3_typecheck_tests.py +++ b/interface/tests/_py3_typecheck_tests.py @@ -1,14 +1,13 @@ -from inspect import signature - 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 @@ -17,11 +16,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 @@ -30,11 +29,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 @@ -43,11 +42,11 @@ 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 diff --git a/interface/tests/test_interface.py b/interface/tests/test_interface.py index 9a2a801..7f86875 100644 --- a/interface/tests/test_interface.py +++ b/interface/tests/test_interface.py @@ -890,3 +890,110 @@ class BadImpl failed to implement interface HasMagicMethodsInterface: - __getitem__(self, key)""" ) assert actual_message == expected_message + + +def test_interface_subclass(): + class A(Interface): # pragma: nocover + def method_a(self): + pass + + class AandB(A): # pragma: nocover + def method_b(self): + pass + + with pytest.raises(InvalidImplementation) as exc: + + class JustA(implements(AandB)): # pragma: nocover + def method_a(self): + pass + + actual_message = str(exc.value) + expected_message = dedent( + """ + class JustA failed to implement interface AandB: + + The following methods of AandB were not implemented: + - method_b(self)""" + ) + assert actual_message == expected_message + + with pytest.raises(InvalidImplementation) as exc: + + class JustB(implements(AandB)): # pragma: nocover + def method_b(self): + pass + + actual_message = str(exc.value) + expected_message = dedent( + """ + class JustB failed to implement interface AandB: + + The following methods of AandB were not implemented: + - method_a(self)""" + ) + assert actual_message == expected_message + + class Both(implements(AandB)): # pragma: nocover + def method_a(self): + pass + + def method_b(self): + pass + + +def test_subclass_conflict_with_different_parents(): + class A(Interface): # pragma: nocover + def method_a(self): + pass + + class B(A): # pragma: nocover + def method_b(self): + pass + + with pytest.raises(TypeError) as exc: + + class C1(A): # pragma: nocover + def method_a(self, x): + pass + + actual_message = str(exc.value) + expected_message = dedent( + """ + Interface field C1.method_a conflicts with inherited field of the same name. + - method_a(self, x) != method_a(self)""" + ) + assert actual_message == expected_message + + with pytest.raises(TypeError) as exc: + + class C2(A): # pragma: nocover + def method_b(self, y, z=None): + pass + + actual_message = str(exc.value) + expected_message = dedent( + """ + Interface field C2.method_b conflicts with inherited field of the same name. + - method_b(self, y, z=None) != method_b(self)""" + ) + assert actual_message == expected_message + + +def test_subclass_allow_compatible_extension(): + class A(Interface): # pragma: nocover + def method_a(self, x): + pass + + class B(A): # pragma: nocover + def method_b(self, y): + pass + + # Any implementation of C1 is also a valid implementation of A and B, so this is fine. + class C1(B): # pragma: nocover + def method_a(self, x, y=3): + pass + + # Same here. + class C2(B): # pragma: nocover + def method_b(self, y, z=None): + pass diff --git a/interface/tests/test_typecheck.py b/interface/tests/test_typecheck.py index def6ca6..47e16f7 100644 --- a/interface/tests/test_typecheck.py +++ b/interface/tests/test_typecheck.py @@ -1,16 +1,16 @@ -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 @@ -18,11 +18,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 @@ -31,11 +31,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 @@ -43,11 +43,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 @@ -56,11 +56,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 @@ -73,5 +73,38 @@ def test_first_argument_name(): assert TypedSignature(lambda: 0).first_argument_name is None -if PY3: # pragma: nocover +def test_typed_signature_repr(): + @TypedSignature + def foo(a, b, c): # pragma: nocover + pass + + expected = "" + assert repr(foo) == expected + + @TypedSignature + @property + def bar(a, b, c): # pragma: nocover + pass + + expected = "" + assert repr(bar) == expected + + @TypedSignature + @classmethod + def baz(a, b, c): # pragma: nocover + pass + + expected = "" + assert repr(baz) == expected + + @TypedSignature + @staticmethod + def fizz(a, b, c): # pragma: nocover + pass + + expected = "" + assert repr(fizz) == expected + + +if PY3: # pragma: nocover-py2 from ._py3_typecheck_tests import * # noqa diff --git a/interface/tests/test_utils.py b/interface/tests/test_utils.py index f64a0af..1a146a3 100644 --- a/interface/tests/test_utils.py +++ b/interface/tests/test_utils.py @@ -1,4 +1,5 @@ from ..compat import unwrap, wraps +from ..functional import merge from ..utils import is_a, unique @@ -20,3 +21,11 @@ def g(*args): # pragma: nocover pass assert unwrap(g) is f + + +def test_merge(): + assert merge([]) == {} + assert merge([{"a": 1, "b": 2}]) == {"a": 1, "b": 2} + + result = merge([{"a": 1}, {"b": 2}, {"a": 3, "c": 4}]) + assert result == {"a": 3, "b": 2, "c": 4} diff --git a/interface/typecheck.py b/interface/typecheck.py index 2ebc259..845a271 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,6 +37,9 @@ 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. """ + # Unwrap to get the underlying inspect.Signature objects. + impl_sig = impl_sig.signature + iface_sig = iface_sig.signature return all( [ positionals_compatible( diff --git a/interface/typed_signature.py b/interface/typed_signature.py index 8c77bfc..1077c54 100644 --- a/interface/typed_signature.py +++ b/interface/typed_signature.py @@ -45,6 +45,11 @@ def type(self): def __str__(self): return str(self._signature) + def __repr__(self): + return "".format( + self._type.__name__, self._signature, + ) + BUILTIN_FUNCTION_TYPES = (types.FunctionType, types.BuiltinFunctionType) From 7493c86c4d6c751f7748f3b0a6ea4d4f7560a2b5 Mon Sep 17 00:00:00 2001 From: Scott Sanderson Date: Tue, 12 May 2020 11:39:48 -0400 Subject: [PATCH 2/3] DOC: Docs for interface subclassing. --- docs/usage.rst | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/usage.rst b/docs/usage.rst index a0d8016..5f52cb6 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -172,3 +172,42 @@ of ``get_all``: Consider changing ReadOnlyMapping.get_all or making these attributes part of ReadOnlyMapping. class ReadOnlyMapping(interface.Interface): + +Interface Subclassing +~~~~~~~~~~~~~~~~~~~~~ + +Interfaces can inherit requirements from other interfaces via subclassing. For +example, if we want to create interfaces for read-write and read-only mappings, +we could do so as follows: + +.. code-block:: python + + class ReadOnlyMapping(interface.Interface): + def get(self, key): + pass + + def keys(self): + pass + + + class ReadWriteMapping(ReadOnlyMapping): + + def set(self, key, value): + pass + + def delete(self, key): + pass + + +An interface that subclasses from another interface inherits all the function +signature requirements from its parent interface. In the example above, a class +implementing ``ReadWriteMapping`` would have to implement ``get``, ``keys``, +``set``, and ``delete``. + +.. warning:: + + Subclassing from an interface is not the same as implementing an + interface. Subclassing from an interface **creates a new interface** that + adds additional methods to the parent interface. Implementing an interface + creates a new class whose method signatures must be compatible with the + interface being implemented. From 80d5719417ffe0f472fc8029ce48245e12f4cdc3 Mon Sep 17 00:00:00 2001 From: Scott Sanderson Date: Tue, 12 May 2020 11:59:40 -0400 Subject: [PATCH 3/3] DOC: Clean up warnings in docs. --- docs/conf.py | 2 +- docs/errors.rst | 2 ++ docs/index.rst | 4 +--- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index cd4a3d0..0c5d50e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -92,7 +92,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +# html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/docs/errors.rst b/docs/errors.rst index 5f9e438..5f137ce 100644 --- a/docs/errors.rst +++ b/docs/errors.rst @@ -1,3 +1,5 @@ +.. _error-detection: + Error Detection --------------- diff --git a/docs/index.rst b/docs/index.rst index 6a2fcda..fd67197 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,7 @@ :mod:`interface` is a library for declaring interfaces and for statically asserting that classes implement those interfaces. It provides stricter semantics than Python's built-in :mod:`abc` module, and it aims to produce -`exceptionally useful error messages`_ when interfaces aren't satisfied. +:ref:`exceptionally useful error messages ` when interfaces aren't satisfied. :mod:`interface` supports Python 2.7 and Python 3.4+. @@ -50,5 +50,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - -.. _`exceptionally useful error messages` : errors.html