Skip to content

[Typing] Functions (and Protocols) as dependencies - and dependees? #168

Open
@JosXa

Description

@JosXa

The type annotation for Binder.bind is as follows:

def bind(
    self,
    interface: Type[T],
    to: Union[None, T, Callable[..., T], Provider[T]] = None,
    scope: Union[None, Type['Scope'], 'ScopeDecorator'] = None,
) -> None:
    ...

The following test case however proves, that Callables are also a valid dependency:

def test_injector_can_inject_a_protocol():
    class Func(Protocol):
        def __call__(self, a: int) -> int:
            ...

    def func_matching_protocol(a: int) -> int:
        return a + 1

    def configure(binder: Binder):
        binder.bind(Func, to=InstanceProvider(func_matching_protocol))

    inj = Injector([configure])
    func = inj.get(Func)
    assert func(1) == 2

[ The Protocol doesn't really have a purpose here other than to show that it's also allowed ]

➡️ Two situations:

  1. This is a bug. You should not be able to declare callables or coroutines as dependencies (I have no idea why that should be true, it's very useful).
  2. It's an intended feature. Then the type annotation should not say interface: Type[T], but rather something else that also includes Callables and Coroutines, and probably also a few other cases.

Continuing 2., Pylance (the new VSCode type checker based on pyright) reports the Func protocol being unassignable to a generic Type:

image

Consequently, inj.get(Func) and func(1) are also being inferred as T, which is a "class instance-like type", not sure what the right term is.

A similar issue arises with @inject, thought there it's more subtle - As far as I understand, the InitializerOrClass helper should be a ClassVar instead of a TypeVar..? I'd have to dig deeper.


Aaand last but not least another related, but different question:

It is abundantly clear that support for @inject on callables has been removed, and i guess you must have had good reasons for that. Assuming I want to shoot myself in the foot, is there a simple workaround to get this feature back with the existing code base? Is it as simple as injector.call_with_injection(...) or is there more to it, and things that might get removed aswell?

I'm thinking that for more performance-critical things that get called a ton, I'd rather not succumb to the DI framework's iron chains and create per-request scoped classes where a bunch of functions would totally suffice.

For context, here is what I'm trying to do:

class Middleware(Protocol[TContext]):
    """ 
    A protocol type is easier to document and has slightly better tooling support than declaring `Callable`s inline. 
    It can also host an `@abstractmethod` decorator. 
    """
    @abstractmethod
    def __call__(
        self, context: TContext, call_next: NextDelegate[TContext]
    ) -> Union[Any, Coroutine[Any, Any, Any]]:
        ...

Library users should be able to bind their own Middleware functions using injector, but also be able to receive additional request-scoped instances. These get then chained together like this:

image

This is going to be a realtime chatbot, so performance matters - and I've seen how nicely FastAPI integrated dependencies nested arbitrarily deep into functions, which is what I'm aiming for. But without the clutter that this arg: Type = Depends(XYZ) syntx brings with it, simply every parameter should be coming from DI or be annotated as NoInject, that pattern is nicer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions