Skip to content

Commit

Permalink
Create unbound @singleton instances in the parent injector (#216)
Browse files Browse the repository at this point in the history
This change introduces consistency to how instances for unbound
classes decorated with a `@singleton` are shared among parent/child
injectors, when auto-binding is enabled.

Classes decorated with `@singleton`, that have not been explicitly
bound, are now created by and bound to the parent injector closest
to the root where all dependencies are fulfilled.

The behavior was like this before, but only when the parent injector
had created the singleton instance (and its implicit binding) before
the child injector. This allows sharing singletons between child
injectors without creating them on the parent injector first.
  • Loading branch information
davidparsson committed Apr 24, 2023
1 parent 87826b3 commit d00556c
Show file tree
Hide file tree
Showing 3 changed files with 193 additions and 3 deletions.
2 changes: 2 additions & 0 deletions docs/scopes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ A (redundant) example showing all three methods::
def provide_thing(self) -> Thing:
return Thing()

If using hierarchies of injectors, classes decorated with `@singleton` will be created by and bound to the parent/ancestor injector closest to the root that can provide all of its dependencies.

Implementing new Scopes
```````````````````````

Expand Down
35 changes: 32 additions & 3 deletions injector/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,13 @@ def is_multibinding(self) -> bool:
return _get_origin(_punch_through_alias(self.interface)) in {dict, list}


@private
class ImplicitBinding(Binding):
"""A binding that was created implicitly by auto-binding."""

pass


_InstallableModuleType = Union[Callable[['Binder'], None], 'Module', Type['Module']]


Expand Down Expand Up @@ -645,12 +652,18 @@ def get_binding(self, interface: type) -> Tuple[Binding, 'Binder']:
# The special interface is added here so that requesting a special
# interface with auto_bind disabled works
if self._auto_bind or self._is_special_interface(interface):
binding = self.create_binding(interface)
binding = ImplicitBinding(*self.create_binding(interface))
self._bindings[interface] = binding
return binding, self

raise UnsatisfiedRequirement(None, interface)

def has_binding_for(self, interface: type) -> bool:
return interface in self._bindings

def has_explicit_binding_for(self, interface: type) -> bool:
return self.has_binding_for(interface) and not isinstance(self._bindings[interface], ImplicitBinding)

def _is_special_interface(self, interface: type) -> bool:
# "Special" interfaces are ones that you cannot bind yourself but
# you can request them (for example you cannot bind ProviderOf(SomeClass)
Expand Down Expand Up @@ -784,10 +797,25 @@ def get(self, key: Type[T], provider: Provider[T]) -> Provider[T]:
try:
return self._context[key]
except KeyError:
provider = InstanceProvider(provider.get(self.injector))
instance = self._get_instance(key, provider, self.injector)
provider = InstanceProvider(instance)
self._context[key] = provider
return provider

def _get_instance(self, key: Type[T], provider: Provider[T], injector: 'Injector') -> T:
if injector.parent and not injector.binder.has_explicit_binding_for(key):
try:
return self._get_instance_from_parent(key, provider, injector.parent)
except (CallError, UnsatisfiedRequirement):
pass
return provider.get(injector)

def _get_instance_from_parent(self, key: Type[T], provider: Provider[T], parent: 'Injector') -> T:
singleton_scope_binding, _ = parent.binder.get_binding(type(self))
singleton_scope = singleton_scope_binding.provider.get(parent)
provider = singleton_scope.get(key, provider)
return provider.get(parent)


singleton = ScopeDecorator(SingletonScope)

Expand Down Expand Up @@ -943,7 +971,8 @@ def run(self):
log.debug(
'%sInjector.get(%r, scope=%r) using %r', self._log_prefix, interface, scope, binding.provider
)
result = scope_instance.get(interface, binding.provider).get(self)
provider_instance = scope_instance.get(interface, binding.provider)
result = provider_instance.get(self)
log.debug('%s -> %r', self._log_prefix, result)
return result

Expand Down
159 changes: 159 additions & 0 deletions injector_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,141 @@ def configure(binder):
a1 = injector1.get(A)
a2 = injector1.get(A)
assert a1.b is a2.b
assert a1 is not a2


def test_injecting_an_auto_bound_decorated_singleton_class():
class A:
@inject
def __init__(self, b: SingletonB):
self.b = b

injector1 = Injector()
a1 = injector1.get(A)
a2 = injector1.get(A)
assert a1.b is a2.b
assert a1 is not a2


def test_a_decorated_singleton_is_shared_between_parent_and_child_injectors_when_parent_creates_it_first():
parent_injector = Injector()

child_injector = parent_injector.create_child_injector()

assert parent_injector.get(SingletonB) is child_injector.get(SingletonB)


def test_a_decorated_singleton_is_shared_between_parent_and_child_injectors_when_child_creates_it_first():
parent_injector = Injector()

child_injector = parent_injector.create_child_injector()

assert child_injector.get(SingletonB) is parent_injector.get(SingletonB)


# Test for https://github.com/python-injector/injector/issues/207
def test_a_decorated_singleton_is_shared_among_child_injectors():
parent_injector = Injector()

child_injector_1 = parent_injector.create_child_injector()
child_injector_2 = parent_injector.create_child_injector()

assert child_injector_1.get(SingletonB) is child_injector_2.get(SingletonB)


def test_a_decorated_singleton_should_not_override_explicit_binds():
parent_injector = Injector()

child_injector = parent_injector.create_child_injector()
grand_child_injector = child_injector.create_child_injector()

bound_singleton = SingletonB()
child_injector.binder.bind(SingletonB, to=bound_singleton)

assert parent_injector.get(SingletonB) is not bound_singleton
assert child_injector.get(SingletonB) is bound_singleton
assert grand_child_injector.get(SingletonB) is bound_singleton


def test_binding_a_singleton_to_a_child_injector_does_not_affect_the_parent_injector():
parent_injector = Injector()

child_injector = parent_injector.create_child_injector()
child_injector.binder.bind(EmptyClass, scope=singleton)

assert child_injector.get(EmptyClass) is child_injector.get(EmptyClass)
assert child_injector.get(EmptyClass) is not parent_injector.get(EmptyClass)
assert parent_injector.get(EmptyClass) is not parent_injector.get(EmptyClass)


def test_a_decorated_singleton_should_not_override_a_child_provider():
parent_injector = Injector()

provided_instance = SingletonB()

class MyModule(Module):
@provider
def provide_name(self) -> SingletonB:
return provided_instance

child_injector = parent_injector.create_child_injector([MyModule])

assert child_injector.get(SingletonB) is provided_instance
assert parent_injector.get(SingletonB) is not provided_instance
assert parent_injector.get(SingletonB) is parent_injector.get(SingletonB)


# Test for https://github.com/python-injector/injector/issues/207
def test_a_decorated_singleton_is_created_as_close_to_the_root_where_dependencies_fulfilled():
class NonInjectableD:
@inject
def __init__(self, required) -> None:
self.required = required

@singleton
class SingletonC:
@inject
def __init__(self, d: NonInjectableD):
self.d = d

parent_injector = Injector()

child_injector_1 = parent_injector.create_child_injector()

child_injector_2 = parent_injector.create_child_injector()
child_injector_2_1 = child_injector_2.create_child_injector()

provided_d = NonInjectableD(required=True)
child_injector_2.binder.bind(NonInjectableD, to=provided_d)

assert child_injector_2_1.get(SingletonC) is child_injector_2.get(SingletonC)
assert child_injector_2.get(SingletonC).d is provided_d

with pytest.raises(CallError):
parent_injector.get(SingletonC)

with pytest.raises(CallError):
child_injector_1.get(SingletonC)


def test_a_bound_decorated_singleton_is_created_as_close_to_the_root_where_it_exists_when_auto_bind_is_disabled():
parent_injector = Injector(auto_bind=False)

child_injector_1 = parent_injector.create_child_injector(auto_bind=False)

child_injector_2 = parent_injector.create_child_injector(auto_bind=False)
child_injector_2_1 = child_injector_2.create_child_injector(auto_bind=False)

child_injector_2.binder.bind(SingletonB)

assert child_injector_2_1.get(SingletonB) is child_injector_2_1.get(SingletonB)
assert child_injector_2_1.get(SingletonB) is child_injector_2.get(SingletonB)

with pytest.raises(UnsatisfiedRequirement):
parent_injector.get(SingletonB)

with pytest.raises(UnsatisfiedRequirement):
child_injector_1.get(SingletonB)


def test_threadlocal():
Expand Down Expand Up @@ -1432,6 +1567,30 @@ def configure(binder):
assert injector.get(Data).name == 'data'


def test_binder_does_not_have_a_binding_for_an_unbound_type():
injector = Injector()
assert not injector.binder.has_binding_for(int)
assert not injector.binder.has_explicit_binding_for(int)


def test_binder_has_binding_for_explicitly_bound_type():
def configure(binder):
binder.bind(int, to=123)

injector = Injector([configure])
assert injector.binder.has_binding_for(int)
assert injector.binder.has_explicit_binding_for(int)


def test_binder_has_implicit_binding_for_implicitly_bound_type():
injector = Injector()

injector.get(int)

assert injector.binder.has_binding_for(int)
assert not injector.binder.has_explicit_binding_for(int)


def test_get_bindings():
def function1(a: int) -> None:
pass
Expand Down

0 comments on commit d00556c

Please sign in to comment.