Super dependency injection in Python

This isn't a blog post about some great dependency injection framework I'm fond of. Rather, it's about using the super() method to change the behaviour of our classes in different contexts. If you're familiar with how super works in single inheritance languages, this might sound a bit mad - in my experience elsewhere, super calls out to the parent class, so if you have class B extending class A, and B calls super anywhere, then class A's method is invoked. However, this is not the case in Python - for classes constructed using multiple inheritance, Python's Method Resolution Order, better known as the MRO, changes dynamically to determine how super is invoked. In effect - if you use super in your class, then one of your children invokes super, the child's parents(of which your class is only one!) determines which method is invoked.

Confused? Let's step through an example and hopefully that'll clear things up.

So, simple enough situation, we're building some kind of service that makes HTTP calls to other services. We're going to use the Requests library instead of writing all our own session handling and so on, but we do want to put some boilerplate stuff like custom headers, logging and error handling in one place, so we'll subclass Session:

import requests


class CustomSession(requests.Session):
    def __init__(self):
        super().__init__()
        self.headers.update({'User-agent': 'CustomSession', 'Accept': 'application/json'})

    def get(self, url, **kwargs):
        print('Performing GET on {}'.format(url))
        resp = super().get(url=url, **kwargs)
        resp.raise_for_status()
        print('{} returned status: {}'.format(url, resp.status_code))
        return resp.json()

With that out of the way, we'll use our CustomSession as a base for our client:

class MyAppClient(CustomSession):
    URL = 'https://myapp.corpdomain/'

    def get_stuff(self):
        resp = super().get(url='{}stuff'.format(self.URL))
        return resp

Hopefully your actual API wouldn't look so poorly, but that's not the point of this post. Anyway, with that done, MyAppClient().get_stuff() makes a GET request to https://myapp.corpdomain/stuff, throws an exception if the status is not 2xx/3xx, and returns the JSON response. Neat. However, we really do not want our tests making live HTTP calls constantly, so this might be a bit of a problem. Let's subclass CustomSession and make a TestSession that overrides our get() and just returns an empty dictionary for now:

class TestSession(CustomSession):
    def get(self, url, **kwargs):
        print('TEST: app tried to GET {}'.format(url))
        return {}

Then let's do the complicated task of creating a test class that contains all the methods in MyAppClient but replaces the get() calls inherited from CustomSession with calls to TestSession:

class TestMyAppClient(MyAppClient, TestSession):
    pass

Now when we run TestMyAppClient().get_stuff(), we see:

TEST: app tried to GET https://myapp.corpdomain/stuff

OK, so what's happening here? Basically the MRO calls the next method, not the parent method in our classes. If we check the help() for our TestMyAppClient, we can see the resolution order:

 |  Method resolution order:
 |      TestMyAppClient
 |      MyAppClient
 |      TestSession
 |      CustomSession
 |      requests.sessions.Session
 |      requests.sessions.SessionRedirectMixin
 |      builtins.object
 |  
 |  Methods inherited from MyAppClient:
 |  
 |  get_stuff(self)

So, we have a super().get() call inherited from MyAppClient, but TestSession is next in our resolution order, so that get() is invoked instead of the CustomSession's, despite MyAppClient having zero knowledge of TestSession.

Of course, this isn't just useful in testing - you could extend your CustomSession to fetch and parse application/xml responses instead of JSON, or have a DebugSession that adds an absurd amount of logging in place of our little print statements - once you're comfortable with determining how super is going to resolve, it opens up all kinds of possibilities.