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.


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}
        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__
            expected_annotations = {}

        for attr_name in cls.__required_attrs__:
                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]
                    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))

                # 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})
            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):


class Addition(Interface):
    max_int: int

    def add(self, x: int, y: int) -> int:

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

    class Calculator():
        def add(self, x: int, y: int) -> int:
            return x + y
except Exception as e:

## Fails on incorrect signatures

    @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:

## Fails on incorrect annotations

    @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:

## Fails on incorrect types (!)

    @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:
  • \$\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\$
    – Roman Susi
    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


Your Answer

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