GithubHelp home page GithubHelp logo

adriangb / di Goto Github PK

View Code? Open in Web Editor NEW
278.0 4.0 13.0 6.52 MB

Pythonic dependency injection

Home Page: https://www.adriangb.com/di/

License: MIT License

Makefile 1.15% Python 98.71% Shell 0.14%
python dependency-injector

di's Introduction

di: dependency injection toolkit

Test Coverage Package version Supported Python versions

di is a modern dependency injection toolkit, modeled around the simplicity of FastAPI's dependency injection.

Key features:

  • Intuitive: simple API, inspired by FastAPI.
  • Auto-wiring: di supports auto-wiring using type annotations.
  • Scopes: inspired by pytest scopes, but defined by users (no fixed "request" or "session" scopes).
  • Composable: decoupled internal APIs give you the flexibility to customize wiring, execution and binding.
  • Performant: di can execute dependencies in parallel and cache results ins scopes. Performance critical parts are written in 🦀 via graphlib2.

Installation

pip install di[anyio]

⚠️ This project is a work in progress. Until there is 1.X.Y release, expect breaking changes. ⚠️

Simple Example

Here is a simple example of how di works:

from dataclasses import dataclass

from di import Container
from di.dependent import Dependent
from di.executors import SyncExecutor


class A:
    ...


class B:
    ...


@dataclass
class C:
    a: A
    b: B


def main():
    container = Container()
    executor = SyncExecutor()
    solved = container.solve(Dependent(C, scope="request"), scopes=["request"])
    with container.enter_scope("request") as state:
        c = solved.execute_sync(executor=executor, state=state)
    assert isinstance(c, C)
    assert isinstance(c.a, A)
    assert isinstance(c.b, B)

For more examples, see our docs.

Why do I need dependency injection in Python? Isn't that a Java thing?

Dependency injection is a software architecture technique that helps us achieve inversion of control and dependency inversion (one of the five SOLID design principles).

It is a common misconception that traditional software design principles do not apply to Python. As a matter of fact, you are probably using a lot of these techniques already!

For example, the transport argument to httpx's Client (docs) is an excellent example of dependency injection. Pytest, arguably the most popular Python test framework, uses dependency injection in the form of pytest fixtures.

Most web frameworks employ inversion of control: when you define a view / controller, the web framework calls you! The same thing applies to CLIs (like click) or TUIs (like Textual). This is especially true for many newer web frameworks that not only use inversion of control but also dependency injection. Two great examples of this are FastAPI and BlackSheep.

For a more comprehensive overview of Python projects related to dependency injection, see Awesome Dependency Injection in Python.

Project Aims

This project aims to be a dependency injection toolkit, with a focus on providing the underlying dependency injection functionality for other libraries.

In other words, while you could use this as a standalone dependency injection framework, you may find it to be a bit terse and verbose. There are also much more mature standalone dependency injection frameworks; I would recommend at least looking into python-dependency-injector since it is currently the most popular / widely used of the bunch.

For more background, see our docs.

di's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

di's Issues

perf: collapse tasks

Given a sequence of single tasks (sync or async), we can collapse them into a single task since they can't be executed in parallel.
This will especially be nice for sync tasks so that an entire sequence of tasks can be pushed to a thread (vs. pushing many small tasks)

Plan for 1.x stable release

The readme mentions there's a plan for a stable release, and breaking changes can be introduced before that. Is there still one?

TaskGroups straddling async context managers are not supported

Because of the fundamental design decision of executors controlling execution of non-context manager dependencies and setup of context manager dependencies but not teardown of context manager dependencies (those get run when the scope is exited via an AsyncExitStack) it is not possible to have a TaskGroup (or anything making use of a CancelScope) straddle the yield in the context manager because the cancel scope would be exited in a different task than it was entered in!

Here's a simplified example of what's going on:

from contextlib import AsyncExitStack, asynccontextmanager
from typing import AsyncContextManager, AsyncIterator, Callable

import anyio

@asynccontextmanager
async def cm_with_cancel_scope() -> AsyncIterator[None]:
    with anyio.CancelScope(): 
        yield

async def run_setup_in_tg(stack: AsyncExitStack, cm: Callable[[], AsyncContextManager[None]]) -> None:
    # Task schedules it's own teardown, which happens outside of the task group we are currently running in
    await stack.enter_async_context(cm())

async def main() -> None:
    async with AsyncExitStack() as stack:  # inside container.enter_scope(
        async with anyio.create_task_group() as tg:  # inside ConcurrentAsyncExecutor.execute
            tg.start_soon(run_setup_in_tg, stack, cm_with_cancel_scope)

anyio.run(main)

Note that this does not impact:

  • Using a TaskGroup that is completely contained within the startup or shutdown of the context manager
  • Anything using AsyncExecutor (and not ConcurrentAsyncExecutor).

The only "solution" to this I can think of is to create the TaskGroup when entering an async scope:

async with container.enter_scope("app"):   # implicitly creates a TaskGroup and somehow passes it down into the executor
    ...

@graingert if you have any thoughts I would love your opinion on this 😄

bug: Protocols throw a WiringError

typing.Protocol overrides __init__ w/ a signature that uses *args, **kwargs

We can either try to detect / special case this, or just become more lenient w.r.t *args, **kwargs since they can technically be empty at runtime.
I think the latter is the better option.
If something is using *args, **kwargs, we can just let it run and maybe fail.

enhancement: dependent call should handle class __call__ when it is generator

Example

Here I use factory (dependent call) as a configurable class with __call__:
https://github.com/maxzhenzhera/di/blob/5302c792bad2f3ee0dc8fae6116756e088b95f87/docs_src/bug_callalbe_class_instance_not_handled_as_factory.py

    with container.enter_scope("request") as state:
        db = solved.execute_sync(executor=SyncExecutor(), state=state)
        print(db)  # <generator object PostgresFactory.__call__ at 0x7f52f19657e0>

But __call__ is not handled and I got just a raw generator.

Workaround

It works if I simply put __call__ as dependent call:

    container.bind(
        bind_by_type(
            Dependent(
                PostgresFactory(db_config).__call__,
                scope="request",
            ),
            DBProtocol,
        ),
    )

Result:

    with container.enter_scope("request") as state:
        db = solved.execute_sync(executor=SyncExecutor(), state=state)
        print(db)
        # Postgres init-ed
        # <__main__.Postgres object at 0x7f1d09713400>
        # Postgres closed

Handling of this case fixed in one Fastapi`s PR: tiangolo/fastapi#1365

feat: mypy plugin

It would be super useful to be able to type check foo: Annotated[int, Dependant(returns_str)]

Naming

I'm thinking of publishing a super alpha version of this package (after a bit of cleanup and writing some minimal docs), mainly because I want to play around with adding DI to Textual or other things outside of FastAPI. Worst case is it makes this package better and gives some ideas for FastAPI.

But that requires coming up with a real name. Right now this is called "anydep" mainly because it does dependency injection and uses anyio to be IO agnostic, but given that that is a minor implementation detail, I'm not sure that should the the package name. Some options that are available on pypi are:

  • anydep: current name, reasons above
  • di (yes, just di): this is the shortest name possible, and DI is a common acronym
  • autodi / autodep: in reference to the fact that this package "autowires" (the terminology used by many DI systems for discovering transitive dependencies via reflection)

Any thoughts @graingert ?

feat: more flexible binds

It might be nice to have more flexible binds, for example the option to:

  • Bind by parameter name
  • Bind by type
  • Bind based on some other marker

This would probably require having a protocol like:

class BindMatcher(Protocol):
    def match(self, param: inspect.Parameter) -> Optional[DependantBase]:
        ...

The risk is that this increases complexity for non-existent use cases.
This will probably not get implemented until it is requested by users.

First pass

@graingert this was my weekend project, an attempt to elaborate upon / understand tiangolo/fastapi#3516 (comment)

I'm still not sure if I captured what you were thinking there (there's no CM factory functions really), but I'm still curious what you think of this in general.

The goal was to take FastAPI's DI system and:

  • generalize it a bit, e.g. not hardcore parameters related to requests/responses, instead use containers to bind these at runtime
  • make it a bit faster (I don't think FastAPI parallelizes the DAG via task groups)
  • use anyio as the backend
  • move as much work as possible to compile / wiring time, in particular I tried to resolve all of the caching during compile time so that at runtime you just run through the DAG, pulling overrides / solved values from a flat dict

bug: Unnecessary computation of sub dependencies of cached dependencies

In the case where B depends on A and B is shared but A is not, if we execute twice without leaving the scope A is cached in, B will get re-computed.

  • Disallow this sort of dependence, but it would break pretty sensible scenarios.
  • Accept this situation: after all, non-shared dependencies are often idempotent and small
  • Implement a ref counting system so that each time we execute we do some sort of DAG traversal, update the ref counts and remove any dependencies with a refcount of 0. We could re-topsort the DAG or not. This might be computationally expensive, require copying data, etc.
  • Put references to the dependents into each dependency's Task and check if all of them are cached before computing. This seems pretty reasonable. It still incurs some back and forth to the event loop, creating task groups and other overhead, but it avoids computing the dependency, which is especially important because that can have side effects. The main issue with this is that the DAG currently stores directed edges; we'd have to store 2 DAGs or do an O(V^2) iteration over the DAG.

Sync interface

Currently, di operates under the model of "convert everything to a coroutine and only handle coroutines". Hence why execute/exeucte_solved are themselves coroutines.

But maybe we should expose a sync interface as well? In theory, if the entire DAG is sync, there's no need to even start up an event loop.

Some pros of this:

  • Simpler docs. Most of our examples could just be sync.
  • Easier to compare w/ other DI frameworks.
  • Usable outside of things that already have an event loop (this is not a benefit for things that already do, like web frameworks or Textual)

Some cons:

  • Complexity.
  • More ways to do the same thing -> more docs.

In terms of implementation, I expect we'd need:

  • Some way to identify nodes as sync or async. Maybe an AsyncTask and SyncTask, or add an DependencyProtocol.sync: bool attribute
  • Keep an ExitStack in addition to an AsyncExitStack for each scope
  • Provide a new entrypoint like execute_sync that will raise an exception if the DAG has any async components
  • Use a ThreadPool instead of anyio TaskGroups within execute_sync

bug: pep 563 (class __init__ annotations parsing)

Example

Modified version of di/docs_src/autowiring.py:

  • added from __future__ import annotations
from __future__ import annotations

import asyncio
from dataclasses import dataclass

from di import Container
from di.dependent import Dependent
from di.executors import AsyncExecutor


@dataclass
class Config:
    host: str = "localhost"


class DBConn:
    def __init__(self, config: Config) -> None:
        self.host = config.host


async def endpoint(conn: DBConn) -> None:
    assert isinstance(conn, DBConn)


async def framework():
    container = Container()
    solved = container.solve(Dependent(endpoint, scope="request"), scopes=["request"])
    async with container.enter_scope("request") as state:
        await solved.execute_async(executor=AsyncExecutor(), state=state)


if __name__ == "__main__":
    asyncio.run(framework())

Traceback:

Traceback (most recent call last):
  File "/home/maxzhenzhera/repos/di/docs_src/autowiring.py", line 33, in <module>
    asyncio.run(framework())
  File "/usr/lib/python3.10/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/usr/lib/python3.10/asyncio/base_events.py", line 649, in run_until_complete
    return future.result()
  File "/home/maxzhenzhera/repos/di/docs_src/autowiring.py", line 27, in framework
    solved = container.solve(Dependent(endpoint, scope="request"), scopes=["request"])
  File "/home/maxzhenzhera/repos/di/di/_container.py", line 649, in solve
    return solve(dependency, scopes, self._bind_hooks, scope_resolver)
  File "/home/maxzhenzhera/repos/di/di/_container.py", line 476, in solve
    root_task = build_task(
  File "/home/maxzhenzhera/repos/di/di/_container.py", line 319, in build_task
    child_task = build_task(
  File "/home/maxzhenzhera/repos/di/di/_container.py", line 307, in build_task
    params = get_params(dependency, binds, path)
  File "/home/maxzhenzhera/repos/di/di/_container.py", line 252, in get_params
    raise WiringError(
di.exceptions.WiringError: The parameter config to <class '__main__.DBConn'> has no dependency marker, no type annotation and no default value. This will produce a TypeError when this function is called. You must either provide a dependency marker, a type annotation or a default value.
Path: Dependent(call=<function endpoint at 0x7f797e183e20>, use_cache=True) -> Dependent(call=<class '__main__.DBConn'>, use_cache=True)

Here we can note:

  • that autowiring with from __future__ import annotations works well for functions: endpoint(conn: DBConn)
  • but does not for classes (that have __init__): class DBConn: def __init__(self, config: Config) -> None:

Explanation

The problem is how real annotations parsed:

di/di/_utils/inspect.py

Lines 68 to 83 in f8b0f4b

def get_parameters(call: Callable[..., Any]) -> Dict[str, inspect.Parameter]:
params: Mapping[str, inspect.Parameter]
if inspect.isclass(call) and (call.__new__ is not object.__new__): # type: ignore[comparison-overlap]
# classes overriding __new__, including some generic metaclasses, result in __new__ getting read
# instead of __init__
params = inspect.signature(call.__init__).parameters # type: ignore[misc] # accessing __init__ directly
params = dict(params)
params.pop(next(iter(params.keys()))) # first parameter to __init__ is self
else:
params = inspect.signature(call).parameters
annotations = get_annotations(call)
processed_params: Dict[str, inspect.Parameter] = {}
for param_name, param in params.items():
param = param.replace(annotation=annotations.get(param_name, param.annotation))
processed_params[param_name] = param
return processed_params

annotations = get_annotations(call)

di/di/_utils/inspect.py

Lines 48 to 65 in f8b0f4b

def get_annotations(call: Callable[..., Any]) -> Dict[str, Any]:
types_from: Callable[..., Any]
if not (
inspect.isclass(call) or inspect.isfunction(call) or inspect.ismethod(call)
) and hasattr(call, "__call__"):
# callable class
types_from = call.__call__ # type: ignore[misc,operator] # accessing __init__ directly
else:
# method
types_from = call
hints = get_type_hints(types_from, include_extras=True)
# for no apparent reason, Annotated[Optional[T]] comes back as Optional[Annotated[Optional[T]]]
# so remove the outer Optional if this is the case
for param_name, hint in hints.items():
args = get_args(hint)
if get_origin(hint) is Union and get_origin(next(iter(args))) is Annotated:
hints[param_name] = next(iter(args))
return hints

Why it occur only with from __future__ import annotations?

If do not perform this future import, so, leave annotations as is (real types),
then => get_parameters() (that works only with signature.inspect) is enough - since annotations are real types:

if inspect.isclass(call) and (call.__new__ is not object.__new__):  # type: ignore[comparison-overlap] 
         # classes overriding __new__, including some generic metaclasses, result in __new__ getting read 
         # instead of __init__ 
         params = inspect.signature(call.__init__).parameters  # type: ignore[misc] # accessing __init__ directly 
         params = dict(params) 
         params.pop(next(iter(params.keys())))  # first parameter to __init__ is self 
     else: 
         params = inspect.signature(call).parameters 

What's wrong?

In my case, I perform future import, so, annotations are stringized.
Therefore, get_parameters() (that works only with signature.inspect) is NOT enough and we dive into get_annotations():

     if not ( 
         inspect.isclass(call) or inspect.isfunction(call) or inspect.ismethod(call) 
     ) and hasattr(call, "__call__"): 
         # callable class 
         types_from = call.__call__  # type: ignore[misc,operator] # accessing __init__ directly 
     else: 
         # method 
         types_from = call 

There is no handling of __init__

We can check it with:

from di._utils.inspect import get_annotations

print(get_annotations(DBConn))  # {}

So, the dependency call for Config is not built, since real annotation is left in stringized form and not replaced with real annotation as it was expected.

Workaround

It works as expected if I just get type hints from class __init__ if it is a class:

def get_annotations(call: Callable[..., Any]) -> Dict[str, Any]:
    types_from: Callable[..., Any]
    if not (
        inspect.isclass(call) or inspect.isfunction(call) or inspect.ismethod(call)
    ) and hasattr(call, "__call__"):
        # callable class
        types_from = call.__call__  # type: ignore[misc,operator] # accessing __init__ directly
    else:
        # method
        types_from = call


    #############################################
    # handle init
    if inspect.isclass(call):
        types_from = call.__init__
    #############################################


    hints = get_type_hints(types_from, include_extras=True)
    # for no apparent reason, Annotated[Optional[T]] comes back as Optional[Annotated[Optional[T]]]
    # so remove the outer Optional if this is the case
    for param_name, hint in hints.items():
        args = get_args(hint)
        if get_origin(hint) is Union and get_origin(next(iter(args))) is Annotated:
            hints[param_name] = next(iter(args))
    return hints

The previous example now works correctly:

from di._utils.inspect import get_annotations

print(get_annotations(DBConn))  # {'config': <class '__main__.Config'>, 'return': <class 'NoneType'>}

Note: breaking commit 722ede4

feat: add `variance` param to Container.register_by_type

To allow binding to "any superclass" (contravariant) or "any subclass" (covariant). Maybe there's better nomenclature for this...

Use case for covariance:

class App:
     def __init__(self):
         ...
         self.container.register_by_type(Dependant(lambda: self), App, covariant=True)

class UsersCustomApp:
    pass

def dep(app: App):. ...

And dep should have the UsersCustomApp instance injected.

Another use case: users want to override the "Request" class used.

contravariant is a bit trickier: not sure why we'd want it, and it would need some sort of bound: type argument. So maybe let's punt on that one.

feat: Only autowire transitive edges between manually wired dependencies

di does auto wiring for dependencies which is super convenient to avoid boilerplate. But let's say you have something like:

class DBConnection:
    def __init__(self, host: str) -> None:
        ...

We (wrongly) assume that this can be constructed like DBConnection(host=str()). This is because we inspect the type annotation and autowire str itself!

We need some way of cutting off how deep we auto-wire. I think a sensible rule would be "all of the leaf dependencies (dependencies with no further dependencies) must be manually wired with:

  • Marker(function_that_accepts_no_arguments)
  • A default value
  • A bind

feat: document / allow customization of dependencies with conflicting scopes

What happens when the same dependency is declared with different scopes for a given DAG? Currently they are treated as different dependencies. But customizing DependantBase.cache_key they can be made to be treated as the same dependency. But this cannot be changed independently of caching. We could have 2 "cache keys", but even then what if we want to have one replace the other, with some specific business logic?

test: smaller tests

As the API stabilizes, we can move from tracer / bullet tests to smaller unit tests.

For example, several tests in test_execute.py can be run against DefaultExecutor directly.

Several wiring tests can be run without execution.

feat: allow access to the Dependant in executors

The main impetus for this feature is to be able to add a marker to say "this sync dependency {should,should not} be executed in a threadpool". Often sync dependencies are small sans-io things, it's super wasteful to put them in a threadpool. Here is an example of such a marker: https://github.com/index-py/index.py/blob/5d7cb5c2de20ef633473bdca58d06306e62a7fed/indexpy/parameters/field_functions.py#L223

I don't think I would add the marker to the DependantProtocol itself. Instead, it can be a contract between the Dependant implementation and the Executor implementation.

What would need changing, as far as interfaces go, is giving access to the dependent from within the Executor. I can see two ways to do this, one which is backwards compatible and one which is not:

Option 1: add the attribute to the executor's task

from __future__ import annotations

from di.types.dependencies import DependantBase

import sys
from typing import Awaitable, Iterable, Union

if sys.version_info < (3, 8):
    from typing_extensions import Protocol
else:
    from typing import Protocol


class Task(Protocol):
    dependent: DependantBase[Any]
    def __call__(
        self,
    ) -> Union[Awaitable[Union[None, Iterable[Task]]], Union[None, Iterable[Task]]]:
        ...

This would require creating a small wrapper within di/_tasks.py.

Option 2: return a tuple from Task

from __future__ import annotations

from di.types.dependencies import DependantBase

import sys
from typing import Awaitable, Iterable, Union

if sys.version_info < (3, 8):
    from typing_extensions import Protocol
else:
    from typing import Protocol


class Task(Protocol):
    dependent: DependantBase[Any]
    def __call__(
        self,
    ) -> Union[Awaitable[Union[None, Iterable[Tuple[Task, Dependant]]], Union[None, Iterable[Tuple[Task, Dependant]]]:
        ...

I like this less. It's not backwards compatible and is more complicated of an interface (requires implementers to do unpacking).

Command line arguments and di

@adriangb Came across this comment of yours from a while back: tiangolo/fastapi#4035 (comment)

The problem I want to solve is passing command line parameters to a dependency injection. In this spirit and also by way of comparison:

a. How can command line arguments be passed to app.state via on_startup (or lifespan)?
b. How can this be achieved with di?

Your code with a command line argument (s: str) to illustrate the problem is:

from fastapi import FastAPI, Depends, Request

class MyObj:
    def __init__(self, something: str) -> None:
        self.something = something

# Pass the command line argument s here and into MyObj()
async def on_startup() -> None:
    app.state.myobj = MyObj(s)

app = FastAPI(on_startup=[on_startup])

def get_myobj(request: Request) -> MyObj:
    return request.app.state.myobj

@app.get("/")
async def index(myobj: MyObj = Depends(get_myobj)) -> None:
    assert isinstance(myobj, MyObj)
    assert myobj.something == s

def main(args):
    params: list[str] = args[2].split()
    s: str = params[0]

    uvicorn.run('main:app', host="127.0.0.1", port=8000, reload=True)

if __name__ == "__main__":
    main(sys.argv)

Remove _results from tasks

We should just store them in a Dict[Task, result].

Currently, if a solved dependant is executed concurrently, task results will overwrite each other since they share an object.

Exception handling tests

Reminder to write some tests for exception handling.
We need to make sure exceptions inside dependencies run in threadpools get raised, etc.

Move execution out of Container into an Executor Protocol

As a possible path for #11, we could move execution out of the container.

We could have an Executor interface that takes a DAG of Tasks and executes them. Then dican provide several implementations of anExecutor`, for example:

  • Move sync deps into a thread, parallelize -> good for IO bound workflows w/ sync deps that do a lot of IO
  • Don't move sync deps into a thread, parallelize -> good for IO bound workflows w/ sync deps that do not do much
  • Only sync, run in threads -> a sync interface, good for IO bound workflows
  • Only sync, run without threads -> a sync interface, good for simple cases where there is no IO

Executor could be a constructor parameter to Container so that users can do something like:

from di import Container, SingleThreadedExecutor
from webframework import App

def get_app() -> App:
    container = Container(executor=SingleThreadedExecutor())
    return App(container=container)

python3.12

Tests run successfully on python3.12, so the only step to make is adding python3.12 to the ci test job (and somehow updating supported versions in README).

Also, it looks like the time has come to drop python3.7,

“Dependent” is misspelled “dependant”

In case you want to fix the spelling.

(Similarly, “dependency” has no “a”.)

Relatedly, there’s a codespell precommit hook that may have caught this (and maybe other spelling errors in this codebase).

Old imports in `Simple Example`

Currently, example at intro has old imports:
from di import Container, Dependent, SyncExecutor

So, this code doesn't work at this moment.

feature / handle generics (actually GenericAlias) in bind_by_type

For now, the support of generics is not guaranteed.

typing.Generic do caching.
But cache size is limited.
When new instances of GenericAlias are being created - the current hook in bind_by_type does not handle this case because it does the is check.

di/di/_container.py

Lines 81 to 98 in e88c8f9

def hook(
param: inspect.Parameter | None, dependent: DependentBase[Any]
) -> DependentBase[Any] | None:
if dependent.call is dependency:
return provider
if param is None:
return None
type_annotation_option = get_type(param)
if type_annotation_option is None:
return None
type_annotation = type_annotation_option.value
if type_annotation is dependency:
return provider
if covariant:
if inspect.isclass(type_annotation) and inspect.isclass(dependency):
if dependency in type_annotation.__mro__:
return provider
return None

I propose to do a == check.
It is completely safe for simple types (default __eq__ is actualy is).
But also this will handle GenericAlias __eq__.

Caching

There are only two hard things in Computer Science: cache invalidation and naming things.
-- Phil Karlton

With #3 out of the way, I think another big design question is going to be caching: what is our caching model going to look like? When do we invalidate caches automatically? How do caches relate to scopes?

Let's use the dependency chain C->B->A (C depends on B...)

If B declares itself as "do not use cache" (currently scope=False), does that mean that every time C is requested A, B and C should be re-computed? Or only A and B should be re-computed? Or only B and C? I think that if C is requested, we should just use the cached value for C. If B is requested, we should re-computed B using the cached value for A.

Perhaps we can make this a bit simpler if we say "you cannot depend on any dependencies bound to inner scopes (relative to yours)". So lifespan dependencies can only depend on other lifespan dependencies. Request dependencies can depend on request dependencies or lifespan dependencies. And so on. But I'm not sure if this prohibits any important use cases. And because scopes are dynamic, we can't compile time check this.

What if between executions, a new value is bound? Let's say all dependencies are cached, and we bind / override A. I think we should not invalidate the caches for anything except maybe A (should we even do that?). So if B or C are requested, cached values are returned. If A is requested, it is re-computed and then re-cached for subsequent requests.

So when should caches be invalidated? Clearly when we exit the caching scope they should be. So if A, B and C are cached in the "request" scope, when we exit the request scope the cached values are dropped. I think this is the only time we should automatically invalidate caches.

chore: get rid of MarkerBase

The intention is that everything in api be the minimum needed to wire everything together.

But the containers don't know or care about Marker's API.

So MarkerBase should be folded into Marker and moved to di.dependant.py. This makes it an implementation detail of the default Dependant instead of a core API.

Allow for non-executing Dependants

It might be interesting to let Dependants with call = None be "non-executable": they exist in SolvedDependant as metadata, but they are not incorporated into the execution graph in any way.

Fix docs build

After the last poetry lock CI job to publish docs failed.

feat: less fragile autowiring

Autowiring is somewhat fragile.
It breaks for example with https.Client because it tries to inject all of the constructor arguments.
Maybe a better approach would be to only try to autowire arguments that do not have defaults.

feat: execute teardown concurrently

We use an {Async}ExitStack to accumulate teardowns and then execute them when we exit the scope.

This is a very convenient data structure, in particular it handles propagating exceptions from one context manager __{a}exit__ to others. But everything is executed synchronously, even if we know that we can parallelize some of it based on the topological sort we're already doing.

The best way to enable both things, as far as I can think, is to wrap the context managers w/ something like:

class IdempotentContextManager:
     exit_run: bool = False
    def __init__(self, cm):
        self.cm = cm
    def __enter__(self):
        return self.cm.__aenter__()
    def __exit__(self, *args):
       if self.exit_run:
           return False  # do not swallow the exception
       res = self.cm.__exit__(*args)
       self.exit_run = True
       return res

Then we can add these __exit__ as tasks at the end of the execution of the target dependency (tasks += [[partial(cm.__exit__, None, None, None) for cm in group] for group in groups]), and they can be executed as normal by the executor.
And if any errors are raised, execution is stopped and any non-executed teardowns will be run by the stack unwinding of {Async}ExitStack.

doc: add missing docstrings

I'm working in adding some missing documentation to:

  • Container
  • SolvedDependant
  • Dependant
  • Dependant
  • Marker
  • AsyncExecutor

I was wondering if adding mkdocstrings would be welcomed as well. I think having an API Reference at the end of the docs would be useful. Thoughts?

Can we make graphlib2 an optional dependency?

Since graphlib2 is a drop-in replacement for the stldib, can we make it an optional extra? I'm looking to package this for Fedora and avoiding another transitive dep might make it easier.

I'd be happy to submit a PR to do this if you're interested.

Add alternative link

Hello there! You made a really awesome project!

As you were, I was inspired by FastAPI Dependency Injection System too. So I created a more lightweight package than yours Fast Depends. In the documentation, I added a link to your project as a more complex and powerful alternative. I think people found your project also can be interested in Fast Depends, so can you add a link to it too? It's not a real concurrent package and I have no plans to grow its functionality in the future. It's just a part of FastAPI can be used everywhere.

Scoping

Aside from caching (#4), another substantial API decision is scopes.

For now at least, we're choosing to combine the concepts of caching and lifecycle under the concept of a "scope". This is similar to what many other DI frameworks do.

The current design goes as follows:

There are 2 "built in" scopes: False and None:

  • False: no caching -> recompute the value every time it is requested, and bind the lifecycle to the individual execute call.
  • None: cache the value within a single execute call and bind the lifecycle to that individual execute call.

Any other scopes are user declared (here user may mean a web framework or similar, not necessarily a person).
Users declare scopes using Container.enter_global_scope and Container.enter_local_scope.
Users can use any hashable value as an identifier for a scope: strings, Enums, etc.
Dependencies scoped to user scopes have their values cached as long as that scope is open, and their cleanup (if they are context managers) is done when the scope exits.

doc: document customization of autowiring

Autowiring can easily customized (e.g. to continue auto wiring when a default value is encountered) by overriding Dependant.gather_dependencies.

It would be good to show an examples of that.

Some ideas:

  • A depth attribute to limit autowiring depth
  • Continuing auto wiring for default values

A nice real world example might be the dependency hierarchy App -> httpx.Client -> httpx.BaseTransport. App accepts a client.
It would be nice to be able to directly inject the transport into the client in a test:

with container.bind(Dependant(lambda: MockTransport(handler=...)), BaseTransport):
    app = container.execute_sync(container.solve(App))

assert isinstance(app.client._base_transport, MockTransport)

feat: better errors

The errors that di currently emits are terrible. The main reason for this is that solving and execution flatten the execution graph (by topologically sorting it). So when you get an error you have no idea what called what.

I propose that during solving we keep track of the "path" to each dependency so that we can pretty format it into error messages (using rich?) and also include it in the error object for programatic access.

  1. During solving if there is a scope conflict, unwireable dependency, etc.
  2. We can pass the "path" to each dependency into the Task object. It will catch any exceptions from calling the dependency and do a raise from including this info.

chore: consider using stdlib's graphlib

We could use this to:

  1. Topsort before we build our task DAG (i.e. replace this code)
  2. Keep track of dependencies as we execute (i.e. replace this code)

My main reservations are:

  1. Performance. Looking at the implementation (which is pure python), I don't see how it could be more performant than what we currently have. In fact, I'm guessing it's slower (mainly due to more error checking). It also may be harder to cache state (we basically use plan dicts of ints right now, which is easy to deep copy and cache, the graphlib data structures idk).
  2. Boilerplate. Might require a bit more boilerplate in the implementation, but would probably even out with the code it replaces.

I think it's probably worth implementing, benchmarking and going from there.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.