4
\$\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)
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.