Skip to content

Commit

Permalink
Refs #9. DI: Scopes - Singleton Scope
Browse files Browse the repository at this point in the history
* added Scopes.SINGLETON
* done some DRYing of the DI Container code
* added some docstrings
  • Loading branch information
lhaze authored and jakos committed Dec 31, 2018
1 parent e016192 commit c6da11b
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 32 deletions.
7 changes: 3 additions & 4 deletions pca/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,11 @@ class DependencyNotFoundError(ConfigError):
"""A dependency was tried to be used but it has not been found"""
DEFAULT_CODE = 'DEPENDENCY-NOT-FOUND'

PRINTED_ATTRS = DharmaError.PRINTED_ATTRS + ('name', 'interface', 'qualifier')
PRINTED_ATTRS = DharmaError.PRINTED_ATTRS + ('identifier', 'qualifier')

def __init__(self, name=None, interface=None, qualifier=None, *args, **kwargs):
def __init__(self, identifier=None, qualifier=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.name = name
self.interface = interface
self.identifier = identifier
self.qualifier = qualifier


Expand Down
105 changes: 82 additions & 23 deletions pca/utils/dependency_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
Kwargs = t.Dict[str, t.Any] # keyword-arguments of a Constructor
ScopeFunction = t.Callable[[Constructor, Kwargs], t.Any]

_SCOPE_TYPE_REF = '__scope_type'


class Container:
"""
Expand All @@ -19,7 +21,8 @@ class Container:
"""

def __init__(self, default_scope: 'Scopes' = None):
self._registry = {}
self._constructor_registry = {}
self._singleton_registry = {}
self._default_scope = default_scope

@staticmethod
Expand All @@ -32,50 +35,97 @@ def register_by_name(
constructor: Constructor,
qualifier: t.Any = None,
kwargs: Kwargs = None,
scope: 'Scopes' = None,
):
"""Registering constructors by name and qualifier."""
key = Container._get_registry_key(name, qualifier)
if key in self._registry:
raise ValueError(f'Ambiguous name: {name}.')
self._registry[key] = (constructor, kwargs)
"""
Registering constructors by name and (optional) qualifier.
:param name: name as the identifier of the constructor registration
:param constructor: a type or a callable that can construct an instance of the dependency.
Expected signature: (Container, **kwargs) -> dependency_instance
:param qualifier: (optional) arbitrary object to narrow the context of identifying
the constructor. The typical use case is a situation when multiple constructors are
registered for the same interface, but for different target components.
:param kwargs: (optional) keyword arguments of the constructor
:param scope: (optional) scope of the registration. If provided, it defines when
the constructor is called to provide a new instance of the dependency. It overrides
scope declared with a `scope` decorator on the constructor, if any, and the default
scope of the container.
"""
self._register(
identifier=name, constructor=constructor, qualifier=qualifier,
kwargs=kwargs, scope=scope
)

def register_by_interface(
self,
interface: type,
constructor: Constructor,
qualifier: t.Any = None,
kwargs: Kwargs = None,
scope: 'Scopes' = None,
):
"""
Registering constructors by interface and (optional) qualifier.
:param interface: a type that defines API of the injected dependency.
:param constructor: a type or a callable that can construct an instance of the dependency.
Expected signature: (Container, **kwargs) -> dependency_instance
:param qualifier: (optional) arbitrary object to narrow the context of identifying
the constructor. The typical use case is a situation when multiple constructors are
registered for the same interface, but for different target components.
:param kwargs: (optional) keyword arguments of the constructor
:param scope: (optional) scope of the registration. If provided, it defines when
the constructor is called to provide a new instance of the dependency. It overrides
scope declared with a `scope` decorator on the constructor, if any, and the default
scope of the container.
"""
# TODO Refs #20: should I register superclasses of the interface as well?
self._register(
identifier=interface, constructor=constructor, qualifier=qualifier,
kwargs=kwargs, scope=scope
)

def _register(
self,
identifier: NameOrInterface,
constructor: Constructor,
qualifier: t.Any = None,
kwargs: Kwargs = None,
scope: 'Scopes' = None,
):
"""Registering constructors by interface and qualifier."""
key = Container._get_registry_key(interface, qualifier)
if key in self._registry:
raise ValueError(f'Ambiguous interface: {interface}.')
self._registry[key] = (constructor, kwargs)
"""Technical detail of registering a constructor"""
key = Container._get_registry_key(identifier, qualifier)
if key in self._constructor_registry:
raise ValueError(f'Ambiguous identifier: {identifier}.')
self._constructor_registry[key] = (constructor, kwargs)
if scope is not None:
setattr(constructor, _SCOPE_TYPE_REF, scope)

def find_by_name(self, name: str, qualifier: t.Any = None) -> t.Any:
"""Finding registered constructors by name."""
key = Container._get_registry_key(name, qualifier)
try:
registered_constructor = self._registry[key]
except KeyError as e:
raise DependencyNotFoundError(name=name, qualifier=qualifier) from e
return self.get_object(*registered_constructor)
"""Finding registered constructor by name."""
return self._find(identifier=name, qualifier=qualifier)

def find_by_interface(self, interface: type, qualifier: t.Any = None) -> t.Any:
"""Finding registered constructors by interface."""
key = Container._get_registry_key(interface, qualifier)
"""Finding registered constructor by interface."""
# TODO Refs #20: should I look for the subclasses of the interface as well?
return self._find(identifier=interface, qualifier=qualifier)

def _find(self, identifier: NameOrInterface, qualifier: t.Any = None) -> t.Any:
key = Container._get_registry_key(identifier, qualifier)
try:
registered_constructor = self._registry[key]
registered_constructor = self._constructor_registry[key]
except KeyError as e:
raise DependencyNotFoundError(interface=interface, qualifier=qualifier) from e
raise DependencyNotFoundError(identifier=identifier, qualifier=qualifier) from e
return self.get_object(*registered_constructor)

def get_object(self, constructor: Constructor, kwargs: Kwargs = None) -> t.Any:
"""
Gets proper scope type and creates instance of registered constructor accordingly.
"""
kwargs = kwargs or {}
scope_function = getattr(constructor, '__scope_type', self._default_scope)
scope_function = getattr(constructor, _SCOPE_TYPE_REF, self._default_scope)
return scope_function(self, constructor, kwargs)

# Implementation of the scopes
Expand All @@ -84,6 +134,14 @@ def instance_scope(self, constructor: Constructor, kwargs: Kwargs) -> t.Any:
"""Every injection makes a new instance."""
return constructor(self, **kwargs)

def singleton_scope(self, constructor: Constructor, kwargs: Kwargs) -> t.Any:
"""First injection makes a new instance, later ones return the same instance."""
try:
instance = self._singleton_registry[constructor]
except KeyError:
instance = self._singleton_registry[constructor] = constructor(self, **kwargs)
return instance


class Component:
"""
Expand All @@ -102,6 +160,7 @@ def __init__(self, container: Container):

class Scopes(Enum):
INSTANCE: ScopeFunction = partial(Container.instance_scope)
SINGLETON: ScopeFunction = partial(Container.singleton_scope)

def __call__(self, container: Container, constructor: Constructor, kwargs: Kwargs):
return self.value(container, constructor, kwargs)
Expand Down
22 changes: 17 additions & 5 deletions pca/utils/tests/test_dependency_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def test_container_interface_duplicates(self, container):
container.register_by_interface(interface, RoadFrame)
with pytest.raises(ValueError) as e:
container.register_by_interface(interface, GravelFrame)
assert str(e.value) == f'Ambiguous interface: {interface}.'
assert str(e.value) == f'Ambiguous identifier: {interface}.'
container.register_by_interface(interface, GravelFrame, qualifier='gravel')

def test_container_interface_not_found(self, container):
Expand All @@ -93,15 +93,15 @@ def test_container_interface_not_found(self, container):
with pytest.raises(DependencyNotFoundError) as error_info:
container.find_by_interface(interface, qualifier)
assert error_info.value.code == 'DEPENDENCY-NOT-FOUND'
assert error_info.value.interface == interface
assert error_info.value.identifier == interface
assert error_info.value.qualifier == qualifier

def test_container_name_duplicates(self, container):
name = 'frame'
container.register_by_name(name=name, constructor=RoadFrame)
with pytest.raises(ValueError) as e:
container.register_by_name(name=name, constructor=GravelFrame)
assert str(e.value) == f'Ambiguous name: {name}.'
assert str(e.value) == f'Ambiguous identifier: {name}.'
container.register_by_name(name=name, constructor=GravelFrame, qualifier='gravel')

def test_container_name_not_found(self, container):
Expand All @@ -110,12 +110,12 @@ def test_container_name_not_found(self, container):
with pytest.raises(DependencyNotFoundError) as error_info:
container.find_by_name(name, qualifier)
assert error_info.value.code == 'DEPENDENCY-NOT-FOUND'
assert error_info.value.name == name
assert error_info.value.identifier == name
assert error_info.value.qualifier == qualifier

def test_scope_class(self, container):
assert repr(Scopes.INSTANCE) == f'<Scopes.{Scopes.INSTANCE.name}>'
assert repr(Scopes.INSTANCE(container, RoadWheel, {})) == f'<Road wheel>'
assert repr(Scopes.INSTANCE(container, RoadWheel, {})) == '<Road wheel>'

def test_constructor_kwargs(self, container):
container.register_by_name(
Expand All @@ -132,6 +132,18 @@ def test_constructor_kwargs(self, container):
'Frame: <Custom pink frame>\nWheels: <Custom pink wheel>'
)

def test_container_registration_scope(self):
pass


class TestScopes:

def test_singleton_scope(self, container):
container.register_by_name(name='frame', constructor=RoadFrame, scope=Scopes.SINGLETON)
instance_1 = container.find_by_name('frame')
instance_2 = container.find_by_name('frame')
assert instance_1 is instance_2


class TestInjectParameters:
@pytest.fixture
Expand Down

0 comments on commit c6da11b

Please sign in to comment.