5
\$\begingroup\$

Edit: Whoops! I just remembered there's already an long-lived package for this, zope.interface. I'd like a review all the same.

This is a proof-of-concept implementation of interfaces in Python that does not use abstract base classes. The rationale for avoiding abstract classes is that multiple inheritance in Python is complicated, and tracing back errors through the multiple resolution order can be difficult. In my (admittedly limited) experience with Python in production, I found that abstract mixin classes make for an ugly source of bugs.

I'm looking for criticism and review in all areas: API, code architecture, code quality/readability/maintainability, potential for "bad things" due to Python's dynamism. Have I just been using classes wrong in Python, and the whole idea stinks?

Right now I'm only targeting new versions of Python (3.6+) because that's what I have installed currently.

interface.py

import inspect
from functools import partial
class InterfaceType(type):
 IGNORED_ATTRS = ['__module__', '__qualname__', '__class__', '__annotations__']
 def __new__(meta, name, bases, attrs):
 annotations = attrs.get('__annotations__', {})
 required_attrs = {k: v for k,v in attrs.items() if k not in meta.IGNORED_ATTRS}
 required_attrs.update(annotations)
 attrs['__required_attrs__'] = required_attrs
 new_cls = type.__new__(meta, name, bases, attrs)
 return new_cls
class Interface(metaclass=InterfaceType):
 def __new__(cls, decorated_class, check_signatures=False, check_annotations=False, enforce_annotations=False):
 if not isinstance(decorated_class, type):
 raise TypeError('Interfaces can only be applied to classes')
 if enforce_annotations:
 check_annotations = True
 if check_annotations:
 # get annotations before we start looping,
 # so we don't have to do it every iteration
 actual_annotations = getattr(decorated_class, '__annotations__', {})
 expected_annotations = cls.__annotations__
 else:
 expected_annotations = {}
 for attr_name in cls.__required_attrs__:
 try:
 actual_val = getattr(decorated_class, attr_name)
 except AttributeError:
 raise ValueError('Class {deco.__name__} is missing required attribute {attr_name}'.\
 format(deco=decorated_class, attr_name=attr_name))
 expected_val = cls.__required_attrs__[attr_name]
 if attr_name in expected_annotations:
 expected_annotation = expected_annotations[attr_name]
 try:
 actual_annotation = actual_annotations[attr_name]
 except KeyError:
 raise ValueError('Attribute {attr_name} has no annotation, but should have annotation {expected_annotation!s}'.\
 format(attr_name=attr_name, expected_annotation=expected_annotation))
 if expected_annotation != actual_annotation:
 raise ValueError('Annotation mismatch for attribute {attr_name}:\n\tActual: {actual_annotation!s}\n\tExpected: {expected_annotation!s}'.\
 format(attr_name=attr_name, actual_annotation=actual_annotation, expected_annotation=expected_annotation))
 if enforce_annotations:
 if not isinstance(actual_val, expected_annotation):
 raise ValueError('Type mismatch for attribute {attr_name}:\n\tActual: {actual_type!s}\n\tExpected: {expected_annotation!s}'.\
 format(attr_name=attr_name, actual_type=type(actual_val), expected_annotation=expected_annotation))
 else:
 # check if actual_val is callable, and not expected val,
 # because if you do something silly like
 # class X():
 # value: int = int
 # it might erroneously try to check the signature of `value`
 # and compare it to the signature of `int`; this will often fail,
 # and will otherwise be incorrect
 if check_signatures and callable(actual_val):
 expected_signature = inspect.signature(expected_val)
 actual_signature = inspect.signature(actual_val)
 if expected_signature != actual_signature:
 raise ValueError('Signature mismatch for callable attribute {attr_name}:\n\tActual: {actual_signature!s}\n\tExpected: {expected_signature!s}'.\
 format(attr_name=attr_name, actual_signature=actual_signature, expected_signature=expected_signature))
 if not hasattr(decorated_class, '__implements__'):
 decorated_class.__implements__ = frozenset({cls})
 else:
 decorated_class.__implements__ |= {cls}
 return decorated_class
def _implements(cls, interfaces, **kwargs):
 if not isinstance(cls, type):
 raise TypeError('implements() can only be applied to types')
 for interface in interfaces:
 if not issubclass(interface, Interface):
 raise TypeError('implements() can only accept Interfaces as arguments')
 cls = interface(cls, **kwargs)
 return cls
def implements(*interfaces, **kwargs):
 kwargs['interfaces'] = interfaces + kwargs.get('interfaces', tuple())
 if len(kwargs['interfaces']) < 1:
 raise ValueError('At least one interface must be specified')
 return partial(_implements, **kwargs)
def hasinterface(cls, interface):
 return interface in getattr(cls, '__implements__', frozenset())

And a quick demo (not quite a test suite):

demo.py

class Addition(Interface):
 max_int: int
 def add(self, x: int, y: int) -> int:
 pass
@implements(Addition)
class Calculator():
 max_int: int = 2**8
 def add(self, x: int, y: int) -> int:
 return x + y
assert hasinterface(Calculator, Addition)
## Fails on missing attributes
try:
 @implements(Addition)
 class Calculator():
 def add(self, x: int, y: int) -> int:
 return x + y
except Exception as e:
 print(e)
## Fails on incorrect signatures
try:
 @implements(Addition, check_signatures=True)
 class Calculator():
 max_int: int = 2**8
 def add(self, x: float, y: float) -> float:
 return x + y
except Exception as e:
 print(e)
## Fails on incorrect annotations
try:
 @implements(Addition, check_annotations=True)
 class Calculator():
 max_int: None = None
 def add(self, x: float, y: float) -> float:
 return x + y
except Exception as e:
 print(e)
## Fails on incorrect types (!)
try:
 @implements(Addition, enforce_annotations=True)
 class Calculator():
 max_int: int = None
 def add(self, x: float, y: float) -> float:
 return x + y
except Exception as e:
 print(e)
200_success
145k22 gold badges190 silver badges478 bronze badges
asked Jul 28, 2017 at 20:04
\$\endgroup\$
2
  • \$\begingroup\$ It's a pity nobody reviewed this. I've found your question when adding mine: codereview.stackexchange.com/questions/268544/… In my implementation I went with Python standard library approach, and am valuing discoverability of interface implementations via string ids and support for IDEs (PyCharm). \$\endgroup\$ Commented Sep 30, 2021 at 17:25
  • \$\begingroup\$ @RomanSusi I totally forgot I even did this! Nowadays I'd use typing.Protocol instead of rolling my own Interface class. I actually have a lot of issues with my own 4-year-old implementation... I might rewrite it and answer my own question. I will take a look at yours too, when I have time. \$\endgroup\$ Commented Sep 30, 2021 at 17:29

0

Know someone who can answer? Share a link to this question via email, Twitter, or Facebook.

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.