Skip to content
Merged
70 changes: 57 additions & 13 deletions injector/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
cast,
Dict,
Generic,
get_args,
Iterable,
List,
Optional,
Expand Down Expand Up @@ -244,6 +245,10 @@ class UnknownArgument(Error):
"""Tried to mark an unknown argument as noninjectable."""


class InvalidInterface(Error):
"""Cannot bind to the specified interface."""


class Provider(Generic[T]):
"""Provides class instances."""

Expand Down Expand Up @@ -355,7 +360,7 @@ class MultiBindProvider(ListOfProviders[List[T]]):
return sequences."""

def get(self, injector: 'Injector') -> List[T]:
return [i for provider in self._providers for i in provider.get(injector)]
return [i for provider in self._providers for i in _ensure_iterable(provider.get(injector))]


class MapBindProvider(ListOfProviders[Dict[str, T]]):
Expand All @@ -368,6 +373,16 @@ def get(self, injector: 'Injector') -> Dict[str, T]:
return map


@private
class KeyValueProvider(Provider[Dict[str, T]]):
def __init__(self, key: str, inner_provider: Provider[T]) -> None:
self._key = key
self._provider = inner_provider

def get(self, injector: 'Injector') -> Dict[str, T]:
return {self._key: self._provider.get(injector)}


_BindingBase = namedtuple('_BindingBase', 'interface provider scope')


Expand Down Expand Up @@ -468,7 +483,7 @@ def bind(
def multibind(
self,
interface: Type[List[T]],
to: Union[List[T], Callable[..., List[T]], Provider[List[T]]],
to: Union[List[Union[T, Type[T]]], Callable[..., List[T]], Provider[List[T]], Type[T]],
scope: Union[Type['Scope'], 'ScopeDecorator', None] = None,
) -> None: # pragma: no cover
pass
Expand All @@ -477,7 +492,7 @@ def multibind(
def multibind(
self,
interface: Type[Dict[K, V]],
to: Union[Dict[K, V], Callable[..., Dict[K, V]], Provider[Dict[K, V]]],
to: Union[Dict[K, Union[V, Type[V]]], Callable[..., Dict[K, V]], Provider[Dict[K, V]]],
scope: Union[Type['Scope'], 'ScopeDecorator', None] = None,
) -> None: # pragma: no cover
pass
Expand All @@ -489,22 +504,25 @@ def multibind(

A multi-binding contributes values to a list or to a dictionary. For example::

binder.multibind(List[str], to=['some', 'strings'])
binder.multibind(List[str], to=['other', 'strings'])
injector.get(List[str]) # ['some', 'strings', 'other', 'strings']
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.


binder.multibind(Dict[str, int], to={'key': 11})
binder.multibind(Dict[str, int], to={'other_key': 33})
injector.get(Dict[str, int]) # {'key': 11, 'other_key': 33}
binder.multibind(dict[str, Interface], to={'key': A})
binder.multibind(dict[str, Interface], to={'other_key': B})
injector.get(dict[str, Interface]) # {'key': <A object at 0x1000>, 'other_key': <B object at 0x2000>}

.. versionchanged:: 0.17.0
Added support for using `typing.Dict` and `typing.List` instances as interfaces.
Deprecated support for `MappingKey`, `SequenceKey` and single-item lists and
dictionaries as interfaces.

: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.

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.


:param to: A list/dict to bind to, where the values are either instances or classes implementing T.
Can also be an explicit :class:`Provider` or a callable that returns a list/dict.
For lists, this can also be a class implementing T (e.g. multibind(list[T], to=A))

:param scope: Optional Scope in which to bind.
"""
if interface not in self._bindings:
Expand All @@ -524,7 +542,27 @@ def multibind(
binding = self._bindings[interface]
provider = binding.provider
assert isinstance(provider, ListOfProviders)
provider.append(self.provider_for(interface, to))

if isinstance(provider, MultiBindProvider) and isinstance(to, list):
try:
element_type = get_args(_punch_through_alias(interface))[0]
except IndexError:
raise InvalidInterface(
f"Use typing.List[T] or list[T] to specify the element type of the list"
)
for element in to:
provider.append(self.provider_for(element_type, element))
elif isinstance(provider, MapBindProvider) and isinstance(to, dict):
try:
value_type = get_args(_punch_through_alias(interface))[1]
except IndexError:
raise InvalidInterface(
f"Use typing.Dict[K, V] or dict[K, V] to specify the value type of the dict"
)
for key, value in to.items():
provider.append(KeyValueProvider(key, self.provider_for(value_type, value)))
else:
provider.append(self.provider_for(interface, to))

def install(self, module: _InstallableModuleType) -> None:
"""Install a module into this binder.
Expand Down Expand Up @@ -696,6 +734,12 @@ def _is_specialization(cls: type, generic_class: Any) -> bool:
return origin is generic_class or issubclass(origin, generic_class)


def _ensure_iterable(item_or_list: Union[T, List[T]]) -> List[T]:
if isinstance(item_or_list, list):
return item_or_list
return [item_or_list]


def _punch_through_alias(type_: Any) -> type:
if (
sys.version_info < (3, 10)
Expand Down
65 changes: 65 additions & 0 deletions injector_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
ClassAssistedBuilder,
Error,
UnknownArgument,
InvalidInterface,
)


Expand Down Expand Up @@ -658,6 +659,70 @@ def provide_passwords(self) -> Passwords:
assert injector.get(Passwords) == {'Bob': 'password1', 'Alice': 'aojrioeg3', 'Clarice': 'clarice30'}


class Plugin(abc.ABC):
pass


class PluginA(Plugin):
pass


class PluginB(Plugin):
pass


class PluginC(Plugin):
pass


class PluginD(Plugin):
pass


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

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


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

injector = Injector([configure])
plugins = injector.get(Dict[str, Plugin])
assert len(plugins) == 4
assert isinstance(plugins['a'], PluginA)
assert isinstance(plugins['b'], PluginB)
assert isinstance(plugins['c'], PluginC)
assert isinstance(plugins['d'], PluginD)


def test__multibinding_to_non_generic_type_raises_error():
def configure_list(binder: Binder):
binder.multibind(List, to=[1])

def configure_dict(binder: Binder):
binder.multibind(Dict, to={'a': 2})

with pytest.raises(InvalidInterface):
Injector([configure_list])

with pytest.raises(InvalidInterface):
Injector([configure_dict])


def test_regular_bind_and_provider_dont_work_with_multibind():
# We only want multibind and multiprovider to work to avoid confusion

Expand Down