5 min read

Clean code 2: How to Interface with Python

In my previous post Clean code 1: Dependency injection explained simply, I tried to make clear why interfaces are useful.

Interfaces allow for clear boundaries between the different logical bricks of your architecture.

Interfaces allow to treat any given technical implementation as a black box, thanks to dependency injection.

That's cool, we're happy
4dglsj

Yet, here's still a question you may ask:

Python doesn't have Interfaces

Let me first point out that this is not a question.

That said, you have a point. Python does not natively have Interfaces.

But Python, when it comes to classes, objects, initialization, lets us implement whatever behaviour we want. Thanks Python !

What interfaces should do

Interfaces should specify the signatures of public methods to implement.
By convention, a non-public method's name starts with _
Since Python has keyword args, our interface should also specify args name.

So, a method signature is defined by:

  • Name
  • Args name
  • Args types
  • Return type

What interfaces may or may not do

We could choose one of two approaches here:

  • Testing interfaces implementation with tests (we would probably want to use mypy)
  • Enforcing interface implementation at runtime

Both have their merits, which I wont go through in this article. We'll see here how to implement the second, but the first will be explored in future articles.

Quick words on testing:

Testing is great, it enforce functional decomposition avoid esoteric bugs.

I must admit, it took me a while to use tests even in small personnal projects. When you're used to using them (also, with the best practices involved doing so) tests may only become an essential tool you're unconfortable doing without.

If you're using unit tests on a daily basis, you're amazing, I like you, and you can skip those next lines.

If you don't, it's only natural to be skeptical about using them. It takes time. More often than not, you're asking questions which answer is trivial and obvious. You could achieve the same quality of code with no tests.

So I thought before using them. Trust me on this... those are NOT fair points. And every developper on earth either would do or are doing a better and faster job with a correct use of tests.

For today, we'll only use simplistic (and a bit dirty) tests. You won't be surprised to read a future article of this series will talk about TDD. In the mean time,

Dependencies

We're going to need pytest, which unsurprisingly, runs tests.
in a console, run:

mkdir interface_python_tutorial
cd interface_python_tutorial
python3.8 -m pip install pytest
touch interface{,_test}.py

Running tests

Each time I'll talk about running test, it can be achieved by simply running: pytest in the directory we created

Implementation

Let's first write our classes without method bodies
file: interface.py

""" Interfaces for Python """
from typing import Dict


class InterfaceException(Exception):
    pass

class Interface:
    @classmethod
    def __check_implements__(cls) -> None:
        """
        :raises: InterfaceError if implemented methods don't match the interface
        :return:
        """
        
    @classmethod
    def _public_methods_signature(cls) -> Dict["str", Dict["str", type]]:
        """
        :return: A dict which keys are method names and values are __annotations__
        """

Interface methods vs implementation

It's important distinguish two things:

In the class scope of an interface, a function we define won't conceptually be the same as a function defined in an implementation.

In the first case it will represent a contract, in the second it will be an implementation of that contract.

In our Interface base class, we defined a function named _public_methods_signature. Which is a pretty self explanatory name.

Signature of a function --including args name, annotated and return type-- can be obtained thanks to the dunder __annotations__.

So, we want our method _public_methods_signature to return a dict matching the name of every members of the class which are:

  • Methods (in other terms: callable)
  • Public (By convention: which name won't start with _)

Expressed in tests those requirements look something like that
file: interface_test.py

import pytest
from interface import Interface, InterfaceError


class ITest(Interface):
    def public_method1(self): pass
    def public_method2(self, foo): pass
    def public_method3(self, foo: str): pass
    def public_method4(self, foo: str) -> int: pass
    def _protected_method(self): pass
    def __private_method(self): pass
    
def test_Interface_public_methods_signature():
    assert ITest._public_methods_signature() == {
        "public_method1": {},
        "public_method2": {},
        "public_method3": {"foo": str},
        "public_method4": {"foo": str, "return": int},
    }

Now, to pass our test (which should always be the goal of every line of code you write, but in depth TDD is a matter for a future article) this code will do:
file: interface.py

...
    @classmethod
    def _public_methods_signature(cls) -> Dict["str", Dict["str", type]]:
        """
        :return: A dict which keys are method names and values are __annotations__
        """
        return {member_name: member.__annotations__
                for member_name, member
                in cls.__dict__.items()
                if callable(member)
                and not member.__name__.startswith('_')}

Enforcing contract implementation

Now we have everything we need to make proper implementation of interfaces enforced at runtime.

What's next:

  • When an interface is declared, we'll extract keep a dict representing our contract
  • When a class implementing an interface, we'll match defined methods against that contract

at the end of our class Interface we'll happend this:
file interface.py

    ...
    def __init_subclass__(cls):
        if cls.__base__ is Interface:
            cls._interface_contract = cls._get_public_methods_signature(cls)
        else:
            cls.__check_implements__()

Here are a few tests expressing the behaviours we'd like:
file interface_test.py

...

def test_Interface():
    with pytest.raises(InterfaceError):
        class TestImplementation(ITest):
            """Nothing implemented, should raise InterfaceError```
        
    with pytest.raises(InterfaceError):
        class TestImplementation(ITest):
            """public_method3 has an arg with the wrong name"""
            def public_method1(self): pass
            def public_method2(self, foo): pass
            def public_method3(self, bar: str): pass
            def public_method4(self, foo: str) -> int: pass

    with pytest.raises(InterfaceError):
        class TestImplementation(ITest):
            """public_method3 has an arg with the wrong type"""
            def public_method1(self): pass
            def public_method2(self, foo): pass
            def public_method3(self, foo: int): pass
            def public_method4(self, foo: str) -> int: pass
            
    class TestImplementation(ITest):
        """Respects the contract
           Should not raise an InterfaceError"""
        def public_method1(self): pass
        def public_method2(self, foo): pass
        def public_method3(self, foo: str): pass
        def public_method4(self, foo: str) -> int: pass
        def _protected_method(self): pass
        def __private_method(self): pass


With what we've got at this point, the only thing we need to passe our tests is to make the method Interface.__check_implements__.

What it should do is:
for each method contractually defined, if there is no implementation with the right signature: raise InterfaceError

Here it is:
file: interface.py

...
    @classmethod
    def __check_implements__(cls):
        """
        :raises: InterfaceError if implemented methods don't match the interface
        :return:
        """
        implemented_methods = cls._public_methods_signature()
        for method_name, method_signature in cls._interface_contract.items():
            if not method_signature == implemented_methods.get(method_name):
                raise InterfaceError(
                    f"{cls.__name__} should implement the method {method_name}"
                    f" with this signature: {method_signature}"
                )
               
...

All the tests should run just fine

Limitations, pitfals, and future

This was meant to give you the very basics of how to use Interfaces in Python. A basic walkthrough of the most straightforward mechanism for that concept.

Here are some limitations, and trades-off we've made (We'll tackle them... in a future article):

Only handle exact match

Built-in python module typing should be handled, and an interface method looking like def foo(self) -> bar should be able to have for implementation def foo(self) -> subtype_of_bar

Doesn't allow for a class to implement more than one interface

Which is for instance allowed in Java

Interface contracts are represented by dicts

Any structure, even short lived or internal to a class should be wrapped into custom types.