Bevy v2.0

Photo by AltumCode on Unsplash

Bevy v2.0

The Python Dependency Injection Framework That Empowers You to Build Better Applications

Modern software can be complex, with many components that depend on each other. It can be hard to manage those dependencies without your project becoming a mess of spaghetti code.

In this article, I'd like to introduce you to Bevy v2.0, a robust Dependency Injection framework that will help you simplify your Python applications.

Bevy has a simple and familiar approach for injecting dependencies into classes and functions. It also makes it very easy to provide alternative implementations of an interface and override how dependencies are determined and created.

Managing Dependencies in Python

When writing code, it's common for one part of your application to depend on other parts of your application. That dependence often goes even deeper with dependencies that have their own dependencies that have their own dependencies. It's also common for one section of code to share similar dependencies with other sections.

A simple blog website is an excellent example of this. You have an authentication function that uses the database connection to look up users and verify that their usernames and passwords are valid. You have another function for creating new blog posts. It needs the database connection to confirm that the user has authenticated and to add the new blog post to the database. That database connection is a shared dependency between the authentication and new blog post functions.

A straightforward solution is to have each function create a database connection.

import os
from databases import SQLite, Post


def authenticate(username: str, password: str):
    database = SQLite(
        os.getenv("APP_DB_HOST"),
        os.getenv("APP_DB_PORT"),
        os.getenv("APP_DB_USERNAME"),
        os.getenv("APP_DB_PASSWORD"),
        os.getenv("APP_DB_NAME"),
    )
    ...


def create_blog_post(title: str, content: str) -> Post:
    database = SQLite(
        os.getenv("APP_DB_HOST"),
        os.getenv("APP_DB_PORT"),
        os.getenv("APP_DB_USERNAME"),
        os.getenv("APP_DB_PASSWORD"),
        os.getenv("APP_DB_NAME"),
    )
    ...

This approach has a few drawbacks. Your code has to wait for the database to connect on every function call, you can't pool connections, and you can't easily switch out database implementations.

You could use a function to check the environment, create a connection to the correct database, and have it store that connection in a global variable. That would fix most of your issues.

import os
from databases import Database, SQLite, PostgreSQL, Post

database = None


def get_database():
    global database
    if not database:
        database = _connect_database()

    return database


def _connect_database() -> Database:
    if os.getenv("APP_ENVIRONMENT") == "DEV":
        return SQLite("db.sqlite")

    return PostgreSQL(
        os.getenv("APP_DB_HOST"),
        os.getenv("APP_DB_PORT"),
        os.getenv("APP_DB_USERNAME"),
        os.getenv("APP_DB_PASSWORD"),
        os.getenv("APP_DB_NAME"),
    )


def authenticate(username: str, password: str):
    database = get_database()
    ...


def create_blog_post(title: str, content: str) -> Post:
    database = get_database()
    ...

That works great until you need to add even more functionality, say, sessions backed by a Redis server. You'd need another function and another global variable, eventually becoming quite cumbersome.

Another solution is to create the dependencies when the application starts and pass them as parameters to the authentication and new blog post functions. The implementations of the dependencies are created outside of the functions and passed to them (injected) when called. This is Dependency Injection (DI) at its simplest.

import os
from databases import Database, SQLite, PostgreSQL, Post
from sessions import Session


def _connect_database() -> Database:
    if os.getenv("APP_ENVIRONMENT") == "DEV":
        return SQLite("db.sqlite")

    return PostgreSQL(
        os.getenv("APP_DB_HOST"),
        os.getenv("APP_DB_PORT"),
        os.getenv("APP_DB_USERNAME"),
        os.getenv("APP_DB_PASSWORD"),
        os.getenv("APP_DB_NAME"),
    )


def _connect_sessions() -> Session:
    return Sessions(
        os.getenv("APP_REDIS_HOST"),
        os.getenv("APP_REDIS_PORT"),
        os.getenv("APP_REDIS_PASSWORD"),
    )

...

def authenticate(
    username: str, 
    password: str, 
    database: Database, 
    session: Session,
):
    ...


def create_blog_post(
    title: str, 
    content: str, 
    database: Database, 
    session: Session,
) -> Post:
    ...


...

database = _connect_database()
session = _connect_sessions()
authenticate("Bob", "SuperSafePwd", database, session)

This DI solution is good because you can choose what implementation to provide. You could pass in an SQLite connection or a PostgreSQL connection. It won't be a problem for the code as long as they have the same interface.

However, even this approach begins to fall apart once you have many dependencies. It requires lots of function parameters that must be passed between your functions.

Let’s look at an example.

def create_blog_post(
    title: str, 
    content: str, 
    database: Database, 
    session: Session,
) -> Post:
    if _can_create_posts(database, session):
        ...


def _can_create_posts(database: Database, session: Sesson) -> bool:
    if not _is_authenticated(session):
        return False

    return _has_new_post_perms(session.get("user"), database)


def _is_authenticated(session: Session) -> bool:
    user = session.get("user")
    if user is None:
        return False

    auth_duration = datetime.now() - user.last_authentication
    return auth_duration > timedelta(days=7)


def _has_new_post_perms(user: User, database: Database) -> bool:
    perms = user.get_permissions(database)
    return perms.CREATE_POSTS or perms.ADMIN

The function create_blog_post takes a database connection and session object that it then passes to _can_create_post. _can_create_post uses session but not database. It then has to pass database to _has_new_post_perms to look up the user's permissions object from the database.

So you have a function that calls a function that calls yet another function, and that last called function is the only one that needs the database connection. This code structure forces you to make dependencies available in your functions by passing them through functions that don't directly need them. Quickly spaghettifying your code with dependencies noodling their way from function to function.

What if I said you could do DI without having to pass arguments through functions that don't need them?

Bevy v2.0: DI Simplified!

Meet Bevy v2.0! With just a few imports, Bevy has everything you need for Pythonic Dependency Injection: no more global variables, no more passing dependencies, and no more dependency spaghetti.

If you've ever used FastAPI, Bevy’s dependency injection will be familiar. Bevy’s approach steps it up and puts DI into hyperdrive, working with every function, every class, everywhere.

Bevy manages the messy global state for you, only requiring you to declare the dependencies your functions and classes have. Simple assignments and type hinting are all you need to take advantage of Bevy.;

If you need even more flexibility, you can change how Bevy resolves, creates, and caches dependencies by creating custom Injection Providers.

You can directly access Bevy’s dependency repository to change dependency implementations or get existing dependencies. Bevy’srepositories are also context-aware, allowing you to have separate repositories for every thread and async task. You can even inherit dependencies across contexts by branching repositories.

Bevy gives you the power and flexibility to do what you need.

How Bevy Works

When you're writing code, you want to write code. You don't want to be distracted by the nuances of some framework. Bevy has that in mind. There is no setup or boilerplate required for it to work. Use type annotations, decorate your functions with bevy.inject, and mark required dependencies with bevy.dependency.

Parameter Injection

Let's start with an example to see how exactly Bevy works.

from bevy import dependency, inject
from databases import database


@inject
def create_user(name: str, database: Database = dependency()):
    if database.get_user_by_name(name):
        raise Exception("A user with that name already exists")

    database.create_new_user(name)


create_user("Bob")

Here is a function that takes a name and a database. The bevy.inject decorator tells Bevy that this function has parameters it has to inject. The database parameter is annotated with the type Database and is assigned the default value bevy.dependency().

It's that simple. Whenever create_user is called, Bevy will inject a Database object if one exists. If none exists, Bevy will handle creating an instance it can use.

Parameter injection works for functions, methods, class methods, and static methods. It even understands functions wrapped by decorators. Bevy’s parameter injection is intelligent enough to ignore parameters passed to the function. It also fully understands function parameters and will correctly handle positional-only, positional, keyword, and keyword-only parameters.

Attribute Injection

Class attribute injection is even more straightforward. Any attribute with a type annotation can be assigned bevy.dependency(). Nothing else is necessary to make the dependency available when the attribute is accessed.

from dataclasses import dataclass
from bevy import dependency
from databases import Database
from cars import Car


@dataclass
class User:
    id: int
    name: str

    database: Database = dependency()

    @property
    def cars(self) -> list[Car]:
        return self.database.get_users_cars(self.id)

    @classmethod
    def get_user(cls, id: int):
        return cls.database.get_user(id)

This dataclass has an database attribute assigned as a dependency. In the cars property, the database attribute is accessed and injected by Bevy. Then in the get_user class method, the database attribute is used and injected without issue as an attribute of the class object.

Bevy's Repository

Bevy stores instances of dependencies in a context global repository object. The current repository can be accessed using the bevy.get_repository function, and the bevy.Repository.set_repository class method sets a new repository in the current context.

Instances are added to the repository when created. They can also be added or updated by calling the repository's set method. The set method takes a key and the value to assign to that key. The key should match the type annotation used to inject the instance.

>>> from bevy import dependency, get_repository, inject
>>> get_repository().set(str, "Spam")
>>> @inject
... def example(word: str = dependency()):
...     print(word)
...
>>> example()
Spam

It's also possible to use a repository's get method to get an instance from the repository using a key. If no instance matching the key is in the repository, Bevy will attempt to create and store an instance for the key. If get cannot find or create an instance for the key, default is returned.

>>> from bevy import dependency, get_repository, inject
>>> repo = get_repository()
>>> repo.set(str, "Spam")
>>> print(repo.get(str))
Spam
>>> print(repo.get(list))
[]
>>> print(repo.get(None, "Null"))
Null

Dependency Constructors

If a dependency is needed but doesn't exist in Bevy’s repository, Bevy will try two things to create an instance for the dependency:

  1. Attempt to call the __bevy_constructor__ class method (ex. Thing.__bevy_constructor__())

  2. Attempt calling the type with no parameters (ex. Thing())

If either returns an instance, Bevy stores that in the repository.

>>> from bevy import dependency, inject
>>> from dataclasses import dataclass
>>> @dataclass
... class Demo:
...     foo: str
...     @classmethod
...     def __bevy_constructor__(cls):
...         return cls("Spam")
...
>>> @inject
... def example(bazz: Demo = dependency()):
...     print(bazz.foo)
...
>>> example()
Spam

typing.Annotated Support

Since Bevy uses the dependency's type annotation as a key to look up the dependency, it's impossible to have multiple instances of the same type available for injection. To support that use case, Bevy relies on typing.Annotated type annotations. Provide the injected type and a hashable key as arguments to Annotated. Bevy will then handle the rest.

>>> from bevy import dependency, get_repository, inject
>>> from typing import Annotated
>>> repo = get_repository()
>>> repo.set(Annotated[str, "SPAM_STRING"], "Spam")
>>> repo.set(Annotated[str, "HASH_STRING"], "Hash")
>>> @inject
... def example(
...     spam: Annotated[str, "SPAM_STRING"] = dependency(),
...     hash: Annotated[str, "HASH_STRING"] = dependency(),
... ):
...     print(f"{spam=}")
...     print(f"{hash=}")
...
>> example()
spam='Spam'
hash='Hash'

If no instance exists for the annotation, Bevy will look for the annotated type.

>>> from bevy import dependency, get_repository, inject
>>> from typing import Annotated
>>> repo = get_repository()
>>> repo.set(str, "No Annotation Found")
>>> @inject
... def example(
...     string: Annotated[str, "SOME_KEY"] = dependency(),
... ):
...     print(f"{string=}")
...
>> example()
spam='No Annotation Found'

In this code, the string "No Annotation Found” is stored in the repository for the str type. There is no match for the dependency Annotated[str, "SOME_KEY"], so Bevy falls back to injecting the string stored for the str type.

Providers

Bevy uses providers that handle resolving, creating, and storing dependencies to make it as flexible as possible. Whenever setting, injecting, or creating a dependency, Bevy’s repository calls each provider to find one that can handle the given key.

Bevy’s default repository has two providers:

  • bevy.providers.AnnotatedProvider: Handles all typing.Annotated type annotations.

  • bevy.providers.TypeProvider: Handles all type annotations that are class objects (isinstance(annotation, type) is True).

The add_providers method adds any number of new providers to the repository. It adds them at a higher priority than all existing providers.

from bevy import get_repository
from bevy.providers.provider import Provider


class CustomProvider(Provider):
    ...


get_repository().add_providers(CustomProvider())

There are six methods that providers can implement. bevy.providers.provider.Provider has basic implementations of all the methods, so it is a good base for new types of providers.

Here's a demo using providers to create dataclasses populated from a JSON config file. The __bevy_constructor__ method can accomplish the same result; I haven’t done that solely to demonstrate a provider implementation.

import dataclasses
from bevy import dependency, get_repository, inject
from bevy.options import Option, Value, Null
from bevy.providers.provider import Provider
from bevy.provider_state import ProviderState
from database import PostgreSQL
from typing import Annotated, Callable, Type, TypeVar
import json


T = TypeVar("T", bound="Config")


class Config:
    """Base class for all config dataclasses."""
    __config_key__: str


@dataclasses.dataclass()
class DBSettings(Config):
    """A Config dataclass for holding database connection details from a config JSON object."""
    __config_key__ = "database"

    host: str
    port: int
    name: str
    user: str
    password: str


class ConfigProvider(Provider[Type[T], T]):
    """A simple provider that handles Config dataclasses. When creating new instances of these dataclasses, they are
    populated with the settings from the config file that is stored in the Bevy repository."""

    def factory(self, key: Type[T], state: ProviderState) -> Option[Callable[[], T]]:
        """Create a factory function that populates a Config dataclass with values from the config JSON file stored in
        the Bevy repository."""
        if self.supports(key):
            return Value(
                lambda: key(
                    **state.repository.get(
                        Annotated[dict, "CONFIG"]
                    ).get(key.__config_key__)
                )
            )

        return Null()

    def supports(self, key: Type[T], _=None) -> bool:
        """Only support class objects that inherit from Config."""
        match key:
            case type() if issubclass(key, Config):
                return True
            case _:
                return False


# Add our new config provider to the Bevy repository
repo = get_repository()
repo.add_providers(ConfigProvider())

# Load our config file and store in the Bevy repository
with open("config.json") as json_file:
    repo.set(
        Annotated[dict, "CONFIG"],
        json.load(json_file),
    )


@inject
def connect_db(settings: DBSettings = dependency()) -> PostgreSQL:
    """Create a PostgreSQL connection using the database settings config dataclass."""
    return PostgreSQL(
        settings.host,
        settings.port,
        settings.name,
        settings.user,
        settings.password,
    )

Context Awareness

Bevy uses contextvars to allow each thread and async task to have separate repositories. The repositories can be branches of other repositories that already have dependencies or can be empty repositories. Repositories provide two methods for controlling the context: set_repository and fork_context.

bevy.Repository.set_repository is a class method that takes an instance of bevy.Repository and sets the contextvar to that repository.

bevy.Repository.fork_context branches the repository and sets the contextvar to the branch. The branch inherits all dependencies and providers.

In Conclusion

Bevy v2.0 helps you manage your code's dependencies as simply as possible without sacrificing flexibility and power.

You can control dependency creation, lookup, and storage using the Bevy constructor and provider APIs. Alternative implementations of any interface are possible to add to any repository. Best of all, this underlying power is accessible in your code with just two functions.

Bevy is one of the most powerful Dependency Injection frameworks that exist for Python. I hope you find it useful in your projects.

Thanks for reading. Feel free to contact me on my socials or GitHub if you have any questions. Check out Bevy’s documentation here and the GitHub here.