litestar-org / litestar

Production-ready, Light, Flexible and Extensible ASGI API framework | Effortlessly Build Performant APIs
https://litestar.dev/
MIT License
5.62k stars 382 forks source link

Enhancement: allow passing dependencies as a sequence in addition to a dictionary #2659

Open guacs opened 1 year ago

guacs commented 1 year ago

Summary

Allow dependencies for DI to be given like [provide_service_one, provide_service_two] instead of {"service_one": provide_service_one, "service_two": provide_service_two}.

The dependencies could be callables or Provide instances where the name of the dependency would be the name of the callable by default. To configure the name, a name field could be provided in Provide which will be used instead of the callable name, if provided.

Basic Example

No response

Drawbacks and Impact

No response

Unresolved questions

No response


[!NOTE]
While we are open for sponsoring on GitHub Sponsors and OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh Litestar dashboard

  • If you would like to see an issue prioritized, make a pledge towards it!
  • We receive the pledge once the issue is completed & verified
  • This, along with engagement in the community, helps us know which features are a priority to our users.

Fund with Polar

Alc-Alc commented 1 year ago

Just for my understanding, where would this be useful or IOW why would the current way (dicts) be limiting one?

guacs commented 1 year ago

Just for my understanding, where would this be useful or IOW why would the current way (dicts) be limiting one?

It's only intended as a nicer API similar to #2422. Also, it'd be nice to add a @provide decorator so we can do something like the following:

from litestar.di import provide

@provide(name="service", sync_to_thread=False)
def provide_service():
    ...

app = Litestar(..., dependencies=[provide_service])

I feel like this is a nicer API than what would be needed now:

from litestar.di import Provide

def provide_service():
    ...

service_provider = Provide(provide_service, sync_to_thread=False)

app = Litestar(..., dependencies={"service": service_provider})
tuukkamustonen commented 10 months ago

Have you considered injection by type and not by name? Something like:

Litestar(dependencies=[Service, Provide(Service, id="other")])

@get()
def endpoint(my_service: Service, other_instance: Annotated[Service, Provide(id="other")]): ...

Not sure about it, but I don't think I've ever seen a DI framework before which would rely on parameter names?

provinzkraut commented 10 months ago

Not sure about it, but I don't think I've ever seen a DI framework before which would rely on parameter names?

The most popular example in Python of this would probably be pytest:

@pytest.fixture(name="something")
def something_fixture() -> str:
 ...

def test_something(something: str) -> None:
 ...

I'd say this is a very common pattern, and it has a few benefits:

As you illustrated in your example, as soon as you're injecting multiple things with the same type, you need an additional identifier (the id parameter in your example) anyway. You might as well use the name for this right away. This is especially a concern when it comes to scalability. Yes, the Litestar way requires a bit more typing upfront for the very simple cases than would be necessary, but as soon as things start to get more complex, it actually requires less typing, while still being very explicit.

Litestar(dependencies={"my_service": Service, "other_instance": Service})

@get()
def endpoint(my_service: Service, other_instance: Service): 
   ...

The other way to solve this is by injecting via provider identity; FastAPI is an example of this:

def provide_service() -> Service:
 ...

def endpoint(my_service: Annotated[Service, Depends(provide_service)]):
 ...

But this then requires you to always have a reference to the provider (not just the provided value type) at the site of injection, introducing a tight coupling which is one of the things DI tries to solve.

tuukkamustonen commented 10 months ago

Hey thanks for the insight.

Yeah, FastAPI's approach is something I dislike - due to need of each time having to declare where to pull the dependency from (probably also the one of the reasons why you've implemented things differently).

Though, you can make it shorter once you inject the same multipe times:

ServiceDep = Annotated[Service, Depends(provide_service)])

def endpoint(service: ServiceDep): ...
def endpoint1(same_one: ServiceDep): ...
...etc...

The benefit in FastAPI's style is that you can click-navigate in IDE into the callable (provide_service) and see how/what is actually constructed and returned. In Litestar, you cannot do that, but got traverse up the app layers and look into dependencies (probably not too much work, though).

But yeah, I understand the idea - having multiple app layers and not having to declare the dependency callable where it gets used.

Probably the cleanest DI interface I've seen on Python is in https://github.com/python-injector/injector (though I haven't actually used it). Copy-paste from their README:

>>> from injector import Injector, inject
>>> class Inner:
...     def __init__(self):
...         self.forty_two = 42
...
>>> class Outer:
...     @inject
...     def __init__(self, inner: Inner):
...         self.inner = inner
...
>>> injector = Injector()
>>> outer = injector.get(Outer)
>>> outer.inner.forty_two
42

You basically say injector.get(Service) without any pre-construction, and it builds the instance on-the-fly if one doesn't already exist in cache. Translating to Litestar it would be:

app = Litestar()

def endpoint(whatever_name: Service): ...

...and Service instance would "just" get constructed. That could be a useful default, because oftentimes we are injecting singletons per class (e.g. UserService, OrganizationService, whatever).

For the cases where a single class needs multiple instances something like id (or qualifier in Spring terms, iirc) could be supported:

app = Litestar()

def special_variant():
    return Service(magic_value="something special")

app = Litestar(dependencies=[Provide(special_variant, id="special2")])

@get()
def endpoint(special2: Annotated[Service, Provide(id="special")]): ...

That's actually the same as currently, but with different (and lengthier) syntax? Injection there occurs with type and name combo, instead of just name as currently, so I don't know πŸ˜…


I have a further question: Have you considered making the DI mechanism live outside the application and not within it. To allow exception handlers, middlewares, etc to be injectable, too, and e.g. controllers could be like:

@dataclass
class MyController(Controller):
    service: Service  # would get injected

    @get()
    def endpoint(self):
        self.service.call_something()

@dataclass
class ExceptionHandler:
    service: Service  # would get injected

    def handle_exception(self, request, exc): ...

app = Litestar(
    route_handlers=[MyController],
    exception_handlers={Exception: ExceptionHandler}
)

In general, the idea of not having to support DI specifically, but rather relying on DI for the whole app construction. No exact coordinates here, but the idea?

provinzkraut commented 10 months ago

That's actually the same as currently, but with different (and lengthier) syntax? Injection there occurs with type and name combo, instead of just name as currently, so I don't know πŸ˜…

Well that was kind of my point earlier ;) There's different trade-offs to be made, but so far I haven't seen anything convincing to drop the explicit naming style, as it covers the most use cases and edge cases reasonably well. The only thing it cannot do is delivery a good IDE integration for "go to source" natively (pytest e.g. gets around this with a plugin/native support from the IDEs :shrug:)

I have a further question: Have you considered making the DI mechanism live outside the application and not within it. To allow exception handlers, middlewares, etc to be injectable, too, and e.g. controllers could be like:

Yes. While not itemized yet, this is planned and one of the major reasons we want to overhaul our DI for 3.0.