DEV Community

Joseph Mancuso for Masonite

Posted on • Edited on

Masonite Python Framework Knowledge Series: Part 1 - Understanding Auto Resolving Dependency Injection

Introduction

The Masonite Python framework is a way more modern approach to Python frameworks than currently anything on the market. A bold statement but once you start using Masonite, you'll be amazed at how powerful it can be just by using a few simple design decisions that other Python frameworks don't have like a dependency injection IOC container, the Mediator (or Manager) pattern which decouples a lot of the framework to keep it fluid and expandable and many more design decisions.

When learning Masonite, you will also learn many of these patterns which are predominant in the software development world.

I'll be writing a few of these articles to really give the background knowledge on how Masonite works. The more you understand your tools, the better you can utilize them to the max.

In this first part of the series I'll go in depth at how Masonite handles what it calls "Auto Resolving Dependency Injection"

If you enjoy this article and enjoy writing applications with Masonite, consider joining the Slack channel: http://slack.masoniteproject.com

What is it?

So dependency injection is a $10 word for a $1 definition. Simply put it just means that we pass something into an object that it needs.

Take this simple example:

from app import Model

def get_user_id(self, model):
    return model.find(1).id

user = get_user_id(Model)
Enter fullscreen mode Exit fullscreen mode

At this most basic example -- model is a dependency for this function and we have just performed dependency injection. We "injected" (or passed) the class into this function object.

A Step Futher

Let's take it one more step further and let's say, for the purposes of this demonstration, that we added a dictionary (that we will call a container) above the function. This dictionary will contain all of our classes we need for our application.

NOTE: This would be a bad way to implement this but just follow along for now :) This is just the basics of understanding how it works.

from static_assets import AmazonUpload

container = {'upload': AmazonUpload}

def upload_image(self, upload):
    return upload.store('some-file.png')
Enter fullscreen mode Exit fullscreen mode

So notice here that we conveniently have a parameter called upload and a dictionary key called upload. This is the most basic foundation of "auto-resolving" dependency injection.

The idea here is that we can use this container to pull in all the dependencies we need for us and we don't have to worry about requirements changing. The container is acting as sort of a Mediator between our concrete code implementation and the fluidness of a container.

If we need to change functionality, we just swap the class in the container.

Another Step Futher

So you might be asking yourself ".. ok? So why do I need this mediator between my objects and these classes? Why wouldn't I just import the driver class that I need and just use that?"

The idea behind the container for Masonite is to "loosely couple" your application (typically concrete implementations) to your features.

Using the upload_image function above, what would happen if the boss came to you and said "HEY! We need to start uploading everything to the file system instead, Amazon has gotten too expensive for our company" .. then what do you do? You'd probably go through your code and see something like this:

from static_assets import AmazonUpload

def upload_image(self):
    return AmazonUpload.store('some-file.png')

def rename_image(self):
    return AmazonUpload.rename('some-file.png', 'another-file.png')

def delete_image(self):
    return AmazonUpload.delete('some-file.png')

def move_image(self):
    return AmazonUpload.move('some-file.png', 'another-location.png')

def copy_image(self):
    return AmazonUpload.copy('some-file.png', 'another-file.png')
Enter fullscreen mode Exit fullscreen mode

Now I have 5 places where I have coded the AmazonUpload dependency and now have 5 concrete implementations that now needs a major refactor.

Let's take another look but let's sneakily put back our container so the parameter name matches the dictionary key again:

from static_assets import AmazonUpload

container = {'upload': AmazonUpload}

def upload_image(self, upload):
    return upload.store('some-file.png')

def rename_image(self, upload):
    return upload.rename('some-file.png', 'another-file.png')

def delete_image(self, upload):
    return upload.delete('some-file.png')

def move_image(self, upload):
    return upload.move('some-file.png', 'another-location.png')

def copy_image(self, upload):
    return upload.copy('some-file.png', 'another-file.png')
Enter fullscreen mode Exit fullscreen mode

The Automatic Part

Ok so now that you understand the concept of "this parameter has the same name as the dictionary key" we can take yet again, another step further.

Replacing the actual functionality of how Masonite handles dependency injection and using pseudo code, the automatic part might look something like:

NOTE: This is pseudo code and not working Python code.

class Container

    providers = {'upload', AmazonUpload}

    def resolve(self, object_to_resolve):
        build_parameter_list = []

        # Get all of the objects parameters
        paramaters = inspect(object_to_resolve).parameters
        # returns ['upload']

        for paramater in paramaters:
            if paramater in self.providers:
                build_parameter_list.append(parameter)

        return object_to_resolve(*build_parameter_list)
Enter fullscreen mode Exit fullscreen mode

That's really it. To explain it in human terms, we are simply using Python inspection to get a list of all the parameters. For example upload_image function above would give us 'upload' because that is the parameter name.

Auto-resolving Dependency Injection

OK! So we have made it to the good stuff. Let's start auto resolving our dependencies! Let's take the upload_image example above:

from container import Container

def upload_image(self, upload):
    return upload.store('some-file.png')

uploaded_image = Container().resolve(upload_image)
# Looks into the container for the 'upload' key and injects it into the class.
Enter fullscreen mode Exit fullscreen mode

We have completely removed the concrete implementation for the upload_image. It is now completely "service agnostic." It doesn't matter if it's an AmazonUpload class or an AzureUpload class and the dependency is only in 1 place. We can now swap functionality throughout out entire application at will.

Masonite Specific

The reason that containers are so powerful is there is a complete Mediator between our features and our application. In addition to this, all features are agnostic of all other features so this creates an extremely pluggable system.

Masonite uses a much more advanced way of finding the objects to resolve.

In Masonite we use this functionality like this:

class WelcomeController:

    def show(self, Request):
        Request.user().id
Enter fullscreen mode Exit fullscreen mode

'Request' is a key in the container. This is a little too basic to let's take it, yet again, another step further and show how Masonite handles Function annotations.

Python Function Annotations

Python annotations have always been sort of quirky. It is basically a comment inline of a parameter. Seems odd. When they added this to Python I'm not sure it was suppose to serve a purpose at all to the core of Python. In fact it says this in the PEP:

The only way that annotations take on meaning is when they are interpreted by third-party libraries. These annotation consumers can do anything they want with a function's annotations.

So what Masonite did was that in addition to looping through the parameter list and resolving the dependencies, we also go through the annotations and resolve them too. Instead of annotating normally like this:

def upload_image(upload: "pass in the upload class"):
    pass
Enter fullscreen mode Exit fullscreen mode

We can take it a step further and pass in an entire class:

from static_files import AmazonUpload

def upload_image(upload: AmazonUpload):
    pass
Enter fullscreen mode Exit fullscreen mode

We are essentially annotating the parameter with a class instead of with a string. We can now use that annotation type (a specific class) to find in the container. This is more of a "get by dictionary value" instead of the previous examples which was "get by dictionary key."

In Masonite there are several parts of the application that are auto-resolved like the controllers, drivers and middleware. Auto resolving in Masonite will look like:

from masonite.view import View
from masonite.request import Request

class WelcomeController:
    ''' Controller For Welcoming The User '''

    def show(self, view: View, request: Request):
        ''' Show Welcome Template '''
        return view.render('welcome', {'request': request})
Enter fullscreen mode Exit fullscreen mode

Subclasses (Advanced)

If you have understood everything above then we can get a little bit more advanced

In addition to fetching the class, we can also fetch a subclass of the class which is even more powerful. The container is a way to keep your application loosely coupled but if we go with annotations, we can't swap out classes from the container at will. We are now "tightly" coupled to the "concrete" implementation. In other words, we can only use 1 class.

Take this for example:

from masonite.drivers import UploadS3Driver

class UploadController:
    ''' Controller For Uploading Images '''

    def show(self, upload: UploadS3Driver):
        ''' Show Welcome Template '''
        return upload.store('some-file.png')
Enter fullscreen mode Exit fullscreen mode

We can take this a step further by adding a parent class. The UploadS3Driver looks like this on the backend:

...
from masonite.contracts import UploadContract
...

class UploadS3Driver(BaseDriver, UploadContract):
    ...
Enter fullscreen mode Exit fullscreen mode

So with this, we can get the same class by just using the UploadContract:

It will be good to know that the UploadContract is not in the container

from masonite.contracts import UploadContract

class UploadController:
    ''' Controller For Uploading Images '''

    def show(self, upload: UploadContract):
        ''' Show Welcome Template '''

        return upload.store('some-file.png')
        # returns an upload driver
Enter fullscreen mode Exit fullscreen mode

How that works

This gets me the UploadS3Driver class because it is a subclass of what we are looking for. Masonite handles this logic on the backend on which it should get.

There is a bit of a hierarchy on what Masonite should resolve.

The basics of it is that it looks for the class itself but if it can't find it (because the contracts are not in the container) then it will resolve a subclass of what you are looking for. If there is no match in the container then it will throw an exception.

Testing

What could be the best part of this structure is that it is extremely testable. Now we can import the controller and mock the parameters.

Here is an example on how we could mock this controller:

from app.http.controllers.WelcomeController import WelcomeController
from masonite.request import Request
from masonite.testsuite.TestSuite import generate_wsgi

class MockView:

    def render(self, template, dictionary):
        pass

def test_welcome_controller():
    assert WelcomeController().show(MockView, Request(generate_wsgi()))
Enter fullscreen mode Exit fullscreen mode

If you keep your controllers short and keep many of your dependencies in the container then you can easily create quick tests and mock objects very easily.


I hope you enjoyed the article! I'll be coming out with more like this if you are interested. You could give it a start on Github or join the Slack channel!

Top comments (0)