Skip to content

feat: Support instantiation with multibind #277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
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
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.


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


: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