Skip to main content

A dependency injection library for Python, Optimized for serverless applications

Project description

ididi

Introduction

ididi is a pythonic dependency injection lib, with ergonomic apis, without boilplate code, works out of the box.

ididi is designed to be non-intrusive, it stays out of your existing code, and is easy to be added/removed.

ididi is 100% strictly typed and well-tested, you can expect excellent typing support.

Source Code Link

Github-ididi

Install

pip install ididi

To view viusal dependency graph, install graphviz

pip install ididi[graphviz]

Usage

Quick Start

import ididi

class Config:
    def __init__(self, env: str = "prod"):
        self.env = env

class Database:
    def __init__(self, config: Config):
        self.config = config

class UserRepository:
    def __init__(self, db: Database):
        self.db = db

class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo

assert isinstance(ididi.solve(UserService), UserService)

Terminology

dependent: a class, or a function that requires arguments to be built/called. dependency: an object that is required by a dependent. resource: if a dependent implements the Async/ContextManager, or it has an async/sync generator as its factory, it is considered a resource.

Automatic dependencies injection

You can use generator/async generator to create a resource that needs to be closed. NOTE:

  1. resources required by a dependent will only be closed when the dependent is exited.
  2. resources will be shared across different dependents only within the same scope, and destroyed when the scope is exited.
  3. error raised when trying to get a async resource in a sync dependent, but sync resource in a async dependent is supported.
from ididi import DependencyGraph
dg = DependencyGraph()

@dg.node
async def get_client() -> ty.AsyncGenerator[Client, None]:
    client = Client()
    try:
        client.open()
        yield client
    finally:
        await client.close()


@dg.node
async def get_db(client: Client) -> ty.AsyncGenerator[DataBase, None]:
    db = DataBase(client)
    assert client.is_opened
    try:
        db.open()
        yield db
    finally:
        await db.close()


@dg.entry
async def main(db: DataBase) -> str:
    assert db.is_opened
    return "ok"

assert await main() == "ok"

Using Scope to manage resources

you might use combination of with or async with statement and dg.scope() to manage resources.

resources will only be shared across dependents only withint the same scope, and will be automatically destryoed and closed when the scope is exited.

NOTE: classes that implment contextlib.AbstractContextManager or contextlib.AbstractAsyncContextManager are also considered to be resources and can be resolved within scope.

@dg.node
def get_resource() -> ty.Generator[Resource, None, None]:
    res =  Resource()
    yield res
    res.close()

with dg.scope() as scope:
    resource = scope.resolve(Resource)

# For async generator
async with dg.scope() as scope:
    resource = await scope.resolve(Resource)

Usage with FastAPI

NOTE: resource is supported

from fastapi import FastAPI
from ididi import DependencyGraph

app = FastAPI()
dg = DependencyGraph()

class AuthService: ...

@dg.node
def auth_service_factory() -> AuthService:
    return AuthService()

Service = ty.Annotated[AuthService, Depends(dg.factory(auth_service_factory))]

@app.get("/")
def get_service(service: Service):
    return service

Visualize the dependency graph(beta)

from ididi import DependencyGraph, Visualizer
dg = DependencyGraph()
vs = Visualizer(dg)

class ConfigService:
    def __init__(self, env: str = "test"):
        self.env = env


class DatabaseService:
    def __init__(self, config: ConfigService):
        self.config = config


class CacheService:
    def __init__(self, config: ConfigService):
        self.config = config


class BaseService:
    def __init__(self, db: DatabaseService):
        self.db = db


class AuthService(BaseService):
    def __init__(self, db: DatabaseService, cache: CacheService):
        super().__init__(db)
        self.cache = cache


class UserService:
    def __init__(self, auth: AuthService, db: DatabaseService):
        self.auth = auth
        self.db = db


class NotificationService:
    def __init__(self, config: ConfigService):
        self.config = config


class EmailService:
    def __init__(self, notification: NotificationService, user: UserService):
        self.notification = notification
        self.user = user

dg.static_resolve(EmailService)
vs.view # use vs.view in jupyter notebook, or use vs.save(path, format) otherwise

image

Lazy Dependency(Beta)

you can use @dg.node(lazy=True) to define a dependent as lazy, which means each of its dependency will not be resolved untill accessed.

start with v0.3.0, lazy node is no longer transitive.

class UserRepo:
    def __init__(self, db: Database):
        self._db = db

    def test(self):
        return "test"

@dg.node(lazy=True)
class ServiceA:
    def __init__(self, user_repo: UserRepo, session_repo: SessionRepo):
        self._user_repo = user_repo
        self._session_repo = session_repo

        assert isinstance(self._user_repo, LazyDependent)
        assert isinstance(self._session_repo, LazyDependent)

    @property
    def user_repo(self) -> UserRepo:
        return self._user_repo

    @property
    def session_repo(self) -> SessionRepo:
        return self._session_repo

assert isinstance(instance.user_repo, LazyDependent)
assert isinstance(instance.session_repo, LazyDependent)

# user_repo would be resolved when user_repo.test() is called.
assert instance.user_repo.test() == "test" 

Runtime override

dg = DependencyGraph()

class Inner:
    def __init__(self, value: str = "inner"):
        self.value = value

@dg.node
class Outer:
    def __init__(self, inner: Inner):
        self.inner = inner

# Override nested dependency
instance = dg.resolve(Outer, inner=Inner(value="overridden"))
assert instance.inner.value == "overridden"

Advanced Usage

ABC

Register ABC implementation with dg.node

you should use dg.node to let ididi know about the implementations of the ABC. you are going to resolve.

from abc import ABC, abstractmethod
class Repository(ABC):
    def __init__(self):
        pass

    @abstractmethod
    def save(self) -> None:
        """Save the repository data."""
        pass

@dag.node
class Repo1(Repository):
    def save(self) -> None:
        pass

@dag.node
class Repo2(Repository):
    def save(self) -> None:
        pass

dag.resolve(Repository)

You might also use __init_subclass__ hook to automatically register implementations.

Multiple Implementations of ABC

ididi will use the last implementation registered to resolve the ABC, you can use a factory to override this behavior.

class Repository(ABC):
    def __init__(self):
        pass

    @abstractmethod
    def save(self) -> None:
        """Save the repository data."""
        pass

@dag.node
class Repo1(Repository):
    def save(self) -> None:
        pass

@dag.node
class Repo2(Repository):
    def save(self) -> None:
        pass

@dag.node
def repo_factory() -> Repository:
    return Repo1()

assert Repository in dag.nodes

repo = dag.resolve(Repository)
assert isinstance(repo, Repo1)

Resolve Rules

  • If a node has a factory, it will be used to create the instance.
  • Otherwise, the node will be created using the __init__ method.
    • Parent's __init__ will be called if no __init__ is defined in the node.
  • whenver there is a default value, it will be used to resolve the dependency.
  • bulitin types are not resolvable by nature, it requires default value to be provided.
  • runtime override with dg.resolve

What and why

What is dependency injection?

If a class requires other classes as its attributes, then these attributes are regarded as dependencies of the class, and the class requiring them is called a dependent.

class Downloader:
    def __init__(self, session: requests.Session):
        self.session = session

Here, Downloader is a dependent, with requests.Session being its dependency.

Dependency injection means dynamically constructing the instances of these dependency classes and then pass them to the dependent class.

the same class without dependency injection looks like this:

class Downloader:
    def __init__(self):
        self.session = requests.Session(url=configured_url, timeout=configured_timeout)

Now, since requests.Session is automatically built with Downloader, it would be difficult to change the behavior of requests.Session at runtime.

Why do we need it?

There are actually a few reasons why you might not need it, the most fundamental one being your code does not need reuseability and flexibility.

  1. If you are writing a script that only runs when you menually execute it, and it is often easier to rewrite the whole script than to modify it, then it probably more efficient to program everything hard-coded. This is actually a common use case of python, DEVOPS, DataAnalysts, etc.

For example, you can actually modify the dependencies of a class at runtime.

class Downloader:
    ...

downloader = Downloader()
downloader.session = requests.Session(url=configured_url, timeout=configured_timeout)

However, this creates a few problems:

  • It is error-prone, you might forget to modify the dependencies, or you might modify the dependencies in the wrong order.
  • It is not typesafe, you might pass the wrong type of dependencies to the class.
  • It is hard to track when the dependencies are modified.

Dependency injection enables you to extend the dependencies of a class without modifying the class itself, which increases the flexibility and reusability of the class.

FAQ

How do I override, or provide a default value for a dependency?

you can use dg.node to create a factory to override the value. you can also have dependencies in your factory, and they will be resolved recursively.

class Config:
    def __init__(self, env: str = "prod"):
        self.env = env

@dg.node
def config_factory() -> Config:
    return Config(env="test")

how do i override a dependent in test?

you can use dg.node with a factory method to override the dependent resolution.

class Cache: ...

class RedisCache(Cache):
    ...

class MemoryCache(Cache):
    ...

@dg.node
def cache_factory(...) -> Cache:
    return RedisCache() 

in your conftest.py:

@dg.node
def memory_cache_factory(...) -> Cache:
    return MemoryCache()    

as this follows LSP, it works both with ididi and type checker.

How do I make ididi reuse a dependencies across different dependent?

by default, ididi will reuse the dependencies across different dependent, you can change this behavior by setting reuse=False in dg.node.

@dg.node(reuse=False) # True by default
class AuthService: ...

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ididi-0.3.0.tar.gz (69.1 kB view details)

Uploaded Source

Built Distribution

ididi-0.3.0-py3-none-any.whl (24.3 kB view details)

Uploaded Python 3

File details

Details for the file ididi-0.3.0.tar.gz.

File metadata

  • Download URL: ididi-0.3.0.tar.gz
  • Upload date:
  • Size: 69.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/5.1.1 CPython/3.12.0

File hashes

Hashes for ididi-0.3.0.tar.gz
Algorithm Hash digest
SHA256 2ec79ceb064c7d66808d81f008022a5e7b62a794205a5e47949916ba7213bf11
MD5 1fa66edf976ab922b3b5607a587f35c4
BLAKE2b-256 88986d7e348eb728f9e5eac418a4bd26bb40ce807b3ba950bb0390a510c627e9

See more details on using hashes here.

File details

Details for the file ididi-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: ididi-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 24.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/5.1.1 CPython/3.12.0

File hashes

Hashes for ididi-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8d28520baf80c40f7ce439bb0de349e4705492cc4fa04703a8ad4c9dd60a1092
MD5 153f18ab2035e279533e7d528c889431
BLAKE2b-256 cae98f3f4419dd5d7ad28167a71d6444edb498424f10090f04c3988d24d829ea

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page