Skip to content

Commit 5a30e9a

Browse files
feat: Support instantiation with multibind (#277)
* Make it possible to register types with multibind, which will then be instantiated the the injector * Augment tests with callable providers * Raise a more helpful error if an attempt is made to multi-bind to a non-generic list/dict * refactor a bit * Update documentation for multibind * test callable provider for dict multibind as well * Add newlines to avoid horizontal scrolling in docs * rewrite to work around mypy quirk * format --------- Co-authored-by: David Pärsson <david@parsson.se>
1 parent 03ea2e1 commit 5a30e9a

File tree

2 files changed

+128
-13
lines changed

2 files changed

+128
-13
lines changed

injector/__init__.py

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
cast,
3030
Dict,
3131
Generic,
32+
get_args,
3233
Iterable,
3334
List,
3435
Optional,
@@ -244,6 +245,10 @@ class UnknownArgument(Error):
244245
"""Tried to mark an unknown argument as noninjectable."""
245246

246247

248+
class InvalidInterface(Error):
249+
"""Cannot bind to the specified interface."""
250+
251+
247252
class Provider(Generic[T]):
248253
"""Provides class instances."""
249254

@@ -355,7 +360,11 @@ class MultiBindProvider(ListOfProviders[List[T]]):
355360
return sequences."""
356361

357362
def get(self, injector: 'Injector') -> List[T]:
358-
return [i for provider in self._providers for i in provider.get(injector)]
363+
result: List[T] = []
364+
for provider in self._providers:
365+
instances: List[T] = _ensure_iterable(provider.get(injector))
366+
result.extend(instances)
367+
return result
359368

360369

361370
class MapBindProvider(ListOfProviders[Dict[str, T]]):
@@ -368,6 +377,16 @@ def get(self, injector: 'Injector') -> Dict[str, T]:
368377
return map
369378

370379

380+
@private
381+
class KeyValueProvider(Provider[Dict[str, T]]):
382+
def __init__(self, key: str, inner_provider: Provider[T]) -> None:
383+
self._key = key
384+
self._provider = inner_provider
385+
386+
def get(self, injector: 'Injector') -> Dict[str, T]:
387+
return {self._key: self._provider.get(injector)}
388+
389+
371390
_BindingBase = namedtuple('_BindingBase', 'interface provider scope')
372391

373392

@@ -468,7 +487,7 @@ def bind(
468487
def multibind(
469488
self,
470489
interface: Type[List[T]],
471-
to: Union[List[T], Callable[..., List[T]], Provider[List[T]]],
490+
to: Union[List[Union[T, Type[T]]], Callable[..., List[T]], Provider[List[T]], Type[T]],
472491
scope: Union[Type['Scope'], 'ScopeDecorator', None] = None,
473492
) -> None: # pragma: no cover
474493
pass
@@ -477,7 +496,7 @@ def multibind(
477496
def multibind(
478497
self,
479498
interface: Type[Dict[K, V]],
480-
to: Union[Dict[K, V], Callable[..., Dict[K, V]], Provider[Dict[K, V]]],
499+
to: Union[Dict[K, Union[V, Type[V]]], Callable[..., Dict[K, V]], Provider[Dict[K, V]]],
481500
scope: Union[Type['Scope'], 'ScopeDecorator', None] = None,
482501
) -> None: # pragma: no cover
483502
pass
@@ -489,22 +508,27 @@ def multibind(
489508
490509
A multi-binding contributes values to a list or to a dictionary. For example::
491510
492-
binder.multibind(List[str], to=['some', 'strings'])
493-
binder.multibind(List[str], to=['other', 'strings'])
494-
injector.get(List[str]) # ['some', 'strings', 'other', 'strings']
511+
binder.multibind(list[Interface], to=A)
512+
binder.multibind(list[Interface], to=[B, C()])
513+
injector.get(list[Interface])
514+
# [<A object at 0x1000>, <B object at 0x2000>, <C object at 0x3000>]
495515
496-
binder.multibind(Dict[str, int], to={'key': 11})
497-
binder.multibind(Dict[str, int], to={'other_key': 33})
498-
injector.get(Dict[str, int]) # {'key': 11, 'other_key': 33}
516+
binder.multibind(dict[str, Interface], to={'key': A})
517+
binder.multibind(dict[str, Interface], to={'other_key': B})
518+
injector.get(dict[str, Interface])
519+
# {'key': <A object at 0x1000>, 'other_key': <B object at 0x2000>}
499520
500521
.. versionchanged:: 0.17.0
501522
Added support for using `typing.Dict` and `typing.List` instances as interfaces.
502523
Deprecated support for `MappingKey`, `SequenceKey` and single-item lists and
503524
dictionaries as interfaces.
504525
505-
:param interface: typing.Dict or typing.List instance to bind to.
506-
:param to: Instance, class to bind to, or an explicit :class:`Provider`
507-
subclass. Must provide a list or a dictionary, depending on the interface.
526+
:param interface: A generic list[T] or dict[str, T] type to bind to.
527+
528+
:param to: A list/dict to bind to, where the values are either instances or classes implementing T.
529+
Can also be an explicit :class:`Provider` or a callable that returns a list/dict.
530+
For lists, this can also be a class implementing T (e.g. multibind(list[T], to=A))
531+
508532
:param scope: Optional Scope in which to bind.
509533
"""
510534
if interface not in self._bindings:
@@ -524,7 +548,27 @@ def multibind(
524548
binding = self._bindings[interface]
525549
provider = binding.provider
526550
assert isinstance(provider, ListOfProviders)
527-
provider.append(self.provider_for(interface, to))
551+
552+
if isinstance(provider, MultiBindProvider) and isinstance(to, list):
553+
try:
554+
element_type = get_args(_punch_through_alias(interface))[0]
555+
except IndexError:
556+
raise InvalidInterface(
557+
f"Use typing.List[T] or list[T] to specify the element type of the list"
558+
)
559+
for element in to:
560+
provider.append(self.provider_for(element_type, element))
561+
elif isinstance(provider, MapBindProvider) and isinstance(to, dict):
562+
try:
563+
value_type = get_args(_punch_through_alias(interface))[1]
564+
except IndexError:
565+
raise InvalidInterface(
566+
f"Use typing.Dict[K, V] or dict[K, V] to specify the value type of the dict"
567+
)
568+
for key, value in to.items():
569+
provider.append(KeyValueProvider(key, self.provider_for(value_type, value)))
570+
else:
571+
provider.append(self.provider_for(interface, to))
528572

529573
def install(self, module: _InstallableModuleType) -> None:
530574
"""Install a module into this binder.
@@ -696,6 +740,12 @@ def _is_specialization(cls: type, generic_class: Any) -> bool:
696740
return origin is generic_class or issubclass(origin, generic_class)
697741

698742

743+
def _ensure_iterable(item_or_list: Union[T, List[T]]) -> List[T]:
744+
if isinstance(item_or_list, list):
745+
return item_or_list
746+
return [item_or_list]
747+
748+
699749
def _punch_through_alias(type_: Any) -> type:
700750
if (
701751
sys.version_info < (3, 10)

injector_test.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
ClassAssistedBuilder,
5555
Error,
5656
UnknownArgument,
57+
InvalidInterface,
5758
)
5859

5960

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

660661

662+
class Plugin(abc.ABC):
663+
pass
664+
665+
666+
class PluginA(Plugin):
667+
pass
668+
669+
670+
class PluginB(Plugin):
671+
pass
672+
673+
674+
class PluginC(Plugin):
675+
pass
676+
677+
678+
class PluginD(Plugin):
679+
pass
680+
681+
682+
def test__multibind_list_of_plugins():
683+
def configure(binder: Binder):
684+
binder.multibind(List[Plugin], to=PluginA)
685+
binder.multibind(List[Plugin], to=[PluginB, PluginC()])
686+
binder.multibind(List[Plugin], to=lambda: [PluginD()])
687+
688+
injector = Injector([configure])
689+
plugins = injector.get(List[Plugin])
690+
assert len(plugins) == 4
691+
assert isinstance(plugins[0], PluginA)
692+
assert isinstance(plugins[1], PluginB)
693+
assert isinstance(plugins[2], PluginC)
694+
assert isinstance(plugins[3], PluginD)
695+
696+
697+
def test__multibind_dict_of_plugins():
698+
def configure(binder: Binder):
699+
binder.multibind(Dict[str, Plugin], to={'a': PluginA})
700+
binder.multibind(Dict[str, Plugin], to={'b': PluginB, 'c': PluginC()})
701+
binder.multibind(Dict[str, Plugin], to=lambda: {'d': PluginD()})
702+
703+
injector = Injector([configure])
704+
plugins = injector.get(Dict[str, Plugin])
705+
assert len(plugins) == 4
706+
assert isinstance(plugins['a'], PluginA)
707+
assert isinstance(plugins['b'], PluginB)
708+
assert isinstance(plugins['c'], PluginC)
709+
assert isinstance(plugins['d'], PluginD)
710+
711+
712+
def test__multibinding_to_non_generic_type_raises_error():
713+
def configure_list(binder: Binder):
714+
binder.multibind(List, to=[1])
715+
716+
def configure_dict(binder: Binder):
717+
binder.multibind(Dict, to={'a': 2})
718+
719+
with pytest.raises(InvalidInterface):
720+
Injector([configure_list])
721+
722+
with pytest.raises(InvalidInterface):
723+
Injector([configure_dict])
724+
725+
661726
def test_regular_bind_and_provider_dont_work_with_multibind():
662727
# We only want multibind and multiprovider to work to avoid confusion
663728

0 commit comments

Comments
 (0)