Skip to content

Conversation

eirikur-nc
Copy link
Contributor

@eirikur-nc eirikur-nc commented Jul 23, 2025

What

Make it possible to multibind to types/classes which then get instantiated when injector.get(<MultiBoundType>) is called. Support this both for lists and dictionaries. Update the multibind documentation accordingly.

Example:

def configure(binder: Binder):
    binder.multibind(List[Plugin], to=PluginA)
    binder.multibind(List[Plugin], to=[PluginB, PluginC()])

injector = Injector([configure])
plugins = injector.get(List[Plugin])
assert len(plugins) == 3
assert isinstance(plugins[0], PluginA)
assert isinstance(plugins[1], PluginB)
assert isinstance(plugins[2], PluginC)

Why

In modular software systems, it's often convenient to leverage plug-in patterns to allow a particular module to hook into certain events. The Guice documentation explains this nicely.

Personally, I have used this pattern for lifecycle listeners that get invoked whenever an application starts up and shuts down. Imagine that you have an application that supports different database back-ends and key-value stores. In such a scenario, we typically have separate DI modules for each database system and each key-value store, e.g.

class MySQLModule(Module):
    ...

class PostgreSQLModule(Module):
    ...

class RedisModule(Module):
    ...
  
class InMemoryCacheModule(Module):
    ...

The injector is then configured using the appropriate set of modules, depending on which database and cache we want to employ, e.g. injector = Injector([PostgreSQLModule, RedisModule]).

Each of these modules can optionally register a lifecycle listener. Here's an example for a listener that closes the connection to redis on shutdown.

class RedisLifecycleListener(LifecycleListener):
    @inject
    def __init__(self, redis: Redis):
        self._redis = redis

    @override
    async def on_startup(self):
        pass

    @override
    async def on_shutdown(self):
        await self._redis.aclose()


class RedisModule(Module):
    def configure(self, binder):
        binder.multibind(list[LifecycleListener], RedisLifecycleListener)

The application code then simply asks for all lifecycle listeners and invokes them on startup and shutdown. Here's an example of a FastAPI lifespan that does this

@asynccontextmanager
async def lifespan(app: FastAPI):
    lifecycle_listeners = injector.get(list[LifecycleListener])
    for listener in lifecycle_listeners:
        await listener.on_startup()

    yield

    for listener in reversed(lifecycle_listeners):
        await listener.on_shutdown()

app = FastAPI(
    title="My API",
    lifespan=lifespan,
)

According to @alecthomas (see #121 (comment)) it was always the intention to support this behavior but up until now, users have had to employ workarounds to get this working.

Prior work

This is similar to #197 in that it solves the problem of multibinding to types. However, the API is quite different.
In #197 the proposal is to multibind to a MultiClassProvider like so

# PR 197
binder.multibind(List[A], to=MultiBindClassProvider([A, B]))

The implementation in this PR foregoes the need for a special wrapper, allowing one to mix and match classes and instances like so

# this PR
binder.multibind(List[Plugin], to=[PluginB, PluginC()])

Moreover, this PR also supports binding to classes without nesting them in lists

# this PR
binder.multibind(List[Plugin], to=PluginA)

It also supports dictionary multibindings

# this PR
def configure(binder: Binder):
    binder.multibind(Dict[str, Plugin], to={'a': PluginA})
    binder.multibind(Dict[str, Plugin], to={'b': PluginB, 'c': PluginC()})

Closes #121

:param interface: typing.Dict or typing.List instance to bind to.
:param to: Instance, class to bind to, or an explicit :class:`Provider`
subclass. Must provide a list or a dictionary, depending on the interface.
:param interface: A generic list[T] or dict[str, T] type to bind to.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to use PEP 585 styled type hints. Support for them was introduced in Python 3.9. While this project still supports Python 3.8, I suspect it's only a matter of time until that support gets dropped since 3.8 has reached end-of-life.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine.

Comment on lines 507 to 509
binder.multibind(list[Interface], to=A)
binder.multibind(list[Interface], to=[B, C()])
injector.get(list[Interface]) # [<A object at 0x1000>, <B object at 0x2000>, <C object at 0x3000>]
Copy link
Contributor Author

@eirikur-nc eirikur-nc Jul 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed these examples to illustrate the use of classes and objects, rather than strings. This improves consistency with the bind documentation.

IMO, people reach for DI libraries to simplify object construction. Injecting strings or other primitive values, while possible, is less beneficial, at least in my experience.

Copy link
Collaborator

@davidparsson davidparsson Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree! The current handling of primitive values has always felt like a side effect to me, so removing that from the documentation is fine by me.

@eirikur-nc eirikur-nc marked this pull request as ready for review July 28, 2025 11:32
Copy link

codecov bot commented Aug 19, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.84%. Comparing base (03ea2e1) to head (90c0ae0).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #277      +/-   ##
==========================================
+ Coverage   95.62%   95.84%   +0.21%     
==========================================
  Files           1        1              
  Lines         572      602      +30     
  Branches       97      103       +6     
==========================================
+ Hits          547      577      +30     
  Misses         19       19              
  Partials        6        6              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@davidparsson
Copy link
Collaborator

Thanks for your contribution, and sorry for the slow handling of this. After a quick glance, I think it looks great. I will try to find the time to make a proper review of this and get this merged and released soon.

Copy link
Collaborator

@davidparsson davidparsson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good. Thanks!

I think this drops the ability to multibind a list of types, but I'm fine sacrificing that for this. Much appreciated!

:param interface: typing.Dict or typing.List instance to bind to.
:param to: Instance, class to bind to, or an explicit :class:`Provider`
subclass. Must provide a list or a dictionary, depending on the interface.
:param interface: A generic list[T] or dict[str, T] type to bind to.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine.

:param interface: typing.Dict or typing.List instance to bind to.
:param to: Instance, class to bind to, or an explicit :class:`Provider`
subclass. Must provide a list or a dictionary, depending on the interface.
:param interface: A generic list[T] or dict[str, T] type to bind to.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here in the docstring, dict[str, T] is mentioned, but it does not have to be a str, right?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I confirmed this.

Comment on lines 507 to 509
binder.multibind(list[Interface], to=A)
binder.multibind(list[Interface], to=[B, C()])
injector.get(list[Interface]) # [<A object at 0x1000>, <B object at 0x2000>, <C object at 0x3000>]
Copy link
Collaborator

@davidparsson davidparsson Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree! The current handling of primitive values has always felt like a side effect to me, so removing that from the documentation is fine by me.

@davidparsson davidparsson merged commit 5a30e9a into python-injector:master Oct 3, 2025
13 checks passed
@davidparsson
Copy link
Collaborator

davidparsson commented Oct 3, 2025

Now this is merged, but I tried to add a few tests. However, I found that the following did not pass:

def test_scopes_applies_to_multibound_type() -> None:
    class A:
        pass

    def configure(binder: Binder) -> None:
        binder.bind(A, to=A, scope=singleton)
        binder.multibind(List[A], to=A)

    injector = Injector([configure])
    first_list = injector.get(List[A])
    second_list = injector.get(List[A])

    assert first_list[0] is second_list[0]

But I think I would expect it to. What are your thoughts, @eirikur-nc?

@davidparsson
Copy link
Collaborator

I'd just like to clarify that I still think it's useful in its current form.

@eirikur-nc
Copy link
Contributor Author

Now this is merged, but I tried to add a few tests. However, I found that the following did not pass:

def test_scopes_applies_to_multibound_type() -> None:
    class A:
        pass

    def configure(binder: Binder) -> None:
        binder.bind(A, to=A, scope=singleton)
        binder.multibind(List[A], to=A)

    injector = Injector([configure])
    first_list = injector.get(List[A])
    second_list = injector.get(List[A])

    assert first_list[0] is second_list[0]

But I think I would expect it to. What are your thoughts, @eirikur-nc?

That's a good point. I'll admit, I was just focusing on getting class instantiation to work. I didn't pay much heed to scopes.

The current implementation of mulitibind does take a scope argument, but it applies to the list/map, rather than its elements. This is probably not obvious to the caller of the multibind method, nor is the fact that the first multibind call for a given type is the one that governs the scope. Subsequent calls cannot alter the scope. If I do this

binder.multibind(List[Plugin], to=PluginA, scope=singleton)
binder.multibind(List[Plugin], to=PluginB)

then the list will be a singleton scoped

binder.multibind(List[Plugin], to=PluginA)
binder.multibind(List[Plugin], to=PluginB, scope=singleton)

then the list will not be scoped.

So I guess there are a few questions that need to be considered

  • Should it be possible to scope the list/map?
    • If so, what should the API for that look like? IMO, that calls for two separate methods, one for declaring the scope of the list/map type and another to add bindings to it.
  • Should the scope argument of multibind be repurposed to apply to the element(s) being added, or should it be removed?
  • If a type is scoped (bind(A, to=A, scope=singleton)), then later multibound without a scope (multibind(List[A], A)), should [inj.get(A)] is inj.get(List[A]) be true?

@eirikur-nc
Copy link
Contributor Author

I've given this some more thought and I think the sensible thing is to mimic the behavior of Guice, since this library is inspired by its design.

Judging from the Guice documentation, it seems like

  • It's not possible to scope the actual container (list/map).
  • When adding a binding to a multi-binder, the scope pertains to the bound element (target) within that multi-binder (and that multi-binder alone).
  • If a type has been bound with a particular scope, and is then bound to a multi-bind container, then the scope should also apply within the multi-bind container.

In other words, the answers to my previous questions are

Q: Should it be possible to scope the list/map?
A: No

Q: Should the scope argument of multibind be repurposed to apply to the element(s) being added, or should it be removed?
A: It should be repurposed to apply to the element(s) being added

Q: If a type is scoped (bind(A, to=A, scope=singleton)), then later multibound without a scope (multibind(List[A], A)), should [inj.get(A)] is inj.get(List[A]) be true?
A: Yes

Would you agree @davidparsson ?

To take this forward, we should probably start by writing up an issue. I wouldn't mind taking a stab at this, but I can't really promise when I'll be able to.

@davidparsson
Copy link
Collaborator

davidparsson commented Oct 5, 2025

I totally agree. All three points seem reasonable and intuitive to me.

I've opened #283, and I'll drop a line there before I start working on it. I think I'm open to releasing this without that, but having the support in place would be ideal.

@davidparsson
Copy link
Collaborator

And by the way, thanks for your thoughts and research. Much appreciated!

@davidparsson
Copy link
Collaborator

davidparsson commented Oct 6, 2025

I kept thinking about this, and I like most aspects of it. One thing I'm not sure of is whether scopes applied to multibound types should apply to the type globally or not. If you're interrested in continuing the discussion, I think we should continue in #283.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How to implement a plug-in system with multibind?

2 participants