From 3843966b305934027f7fcc59c4c973f8848fe9b2 Mon Sep 17 00:00:00 2001 From: David Runemalm Date: Sat, 2 Mar 2024 18:42:51 +0700 Subject: [PATCH 1/3] Add support for constructor arguments. --- README.md | 15 ++ docs/versionhistory.rst | 16 +++ src/dependency_injection/container.py | 65 +++++++-- src/dependency_injection/registration.py | 5 +- tests/unit_test/car.py | 5 - .../register/test_register_scoped.py | 14 +- .../register/test_register_singleton.py | 14 +- .../register/test_register_transient.py | 17 ++- .../register/test_register_with_args.py | 22 +++ .../container/resolve/test_resolve_scoped.py | 14 +- .../resolve/test_resolve_singleton.py | 14 +- .../resolve/test_resolve_transient.py | 15 +- .../resolve/test_resolve_with_args.py | 135 ++++++++++++++++++ tests/unit_test/decorator/test_decorator.py | 44 +++++- tests/unit_test/vehicle.py | 2 - 15 files changed, 361 insertions(+), 36 deletions(-) delete mode 100644 tests/unit_test/car.py create mode 100644 tests/unit_test/container/register/test_register_with_args.py create mode 100644 tests/unit_test/container/resolve/test_resolve_with_args.py delete mode 100644 tests/unit_test/vehicle.py diff --git a/README.md b/README.md index 1e0a3e7..afc60e8 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,21 @@ To contribute, create a pull request on the develop branch following the [git fl ## Release Notes +### [1.0.0-alpha.4](https://github.com/runemalm/py-dependency-injection/releases/tag/v1.0.0-alpha.4) (2024-03-02) + +- **New Feature**: Support for constructor arguments in dependency registration: In this release, we introduce the ability to specify constructor arguments when registering dependencies with the container. This feature provides more flexibility when configuring dependencies, allowing users to customize the instantiation of classes during registration. + + **Usage Example:** + ```python + # Registering a dependency with constructor arguments + dependency_container.register_transient( + SomeInterface, SomeClass, + constructor_args={"arg1": value1, "arg2": value2} + ) + ``` + + Users can now pass specific arguments to be used during the instantiation of the dependency. This is particularly useful when a class requires dynamic or configuration-dependent parameters. + ### [1.0.0-alpha.3](https://github.com/runemalm/py-dependency-injection/releases/tag/v1.0.0-alpha.3) (2024-03-02) - **Breaking Change**: Restriction on `@inject` Decorator: Starting from this version, the `@inject` decorator can now only be used on static class methods and class methods. This change is introduced due to potential pitfalls associated with resolving and injecting dependencies directly into class instance methods using the dependency container. diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index c83c7a5..c8de9c0 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -2,6 +2,22 @@ Version history ############### +**1.0.0-alpha.4 (2024-03-02)** + +- **New Feature**: Support for constructor arguments in dependency registration: In this release, we introduce the ability to specify constructor arguments when registering dependencies with the container. This feature provides more flexibility when configuring dependencies, allowing users to customize the instantiation of classes during registration. + + **Usage Example:**:: + + # Registering a dependency with constructor arguments + dependency_container.register_transient( + SomeInterface, SomeClass, + constructor_args={"arg1": value1, "arg2": value2} + ) + + Users can now pass specific arguments to be used during the instantiation of the dependency. This is particularly useful when a class requires dynamic or configuration-dependent parameters. + +`View release on GitHub `_ + **1.0.0-alpha.3 (2024-03-02)** - **Breaking Change**: Restriction on `@inject` Decorator: Starting from this version, the `@inject` decorator can now only be used on static class methods and class methods. This change is introduced due to potential pitfalls associated with resolving and injecting dependencies directly into class instance methods using the dependency container. diff --git a/src/dependency_injection/container.py b/src/dependency_injection/container.py index 6c92f4e..09cd0ab 100644 --- a/src/dependency_injection/container.py +++ b/src/dependency_injection/container.py @@ -1,4 +1,5 @@ import inspect +from typing import Any, Dict, Type from dependency_injection.registration import Registration from dependency_injection.scope import DEFAULT_SCOPE_NAME, Scope @@ -25,20 +26,20 @@ def get_instance(cls, name=None): return cls._instances[(cls, name)] - def register_transient(self, interface, class_): + def register_transient(self, interface, class_, constructor_args=None): if interface in self._registrations: raise ValueError(f"Dependency {interface} is already registered.") - self._registrations[interface] = Registration(interface, class_, Scope.TRANSIENT) + self._registrations[interface] = Registration(interface, class_, Scope.TRANSIENT, constructor_args) - def register_scoped(self, interface, class_): + def register_scoped(self, interface, class_, constructor_args=None): if interface in self._registrations: raise ValueError(f"Dependency {interface} is already registered.") - self._registrations[interface] = Registration(interface, class_, Scope.SCOPED) + self._registrations[interface] = Registration(interface, class_, Scope.SCOPED, constructor_args) - def register_singleton(self, interface, class_): + def register_singleton(self, interface, class_, constructor_args=None): if interface in self._registrations: raise ValueError(f"Dependency {interface} is already registered.") - self._registrations[interface] = Registration(interface, class_, Scope.SINGLETON) + self._registrations[interface] = Registration(interface, class_, Scope.SINGLETON, constructor_args) def resolve(self, interface, scope_name=DEFAULT_SCOPE_NAME): if scope_name not in self._scoped_instances: @@ -50,24 +51,62 @@ def resolve(self, interface, scope_name=DEFAULT_SCOPE_NAME): registration = self._registrations[interface] dependency_scope = registration.scope dependency_class = registration.class_ + constructor_args = registration.constructor_args + + self._validate_constructor_args(constructor_args=constructor_args, class_=dependency_class) if dependency_scope == Scope.TRANSIENT: - return self._inject_dependencies(dependency_class) + return self._inject_dependencies( + class_=dependency_class, + constructor_args=constructor_args + ) elif dependency_scope == Scope.SCOPED: if interface not in self._scoped_instances[scope_name]: self._scoped_instances[scope_name][interface] = ( self._inject_dependencies( class_=dependency_class, - scope_name=scope_name,)) + scope_name=scope_name, + constructor_args=constructor_args, + )) return self._scoped_instances[scope_name][interface] elif dependency_scope == Scope.SINGLETON: if interface not in self._singleton_instances: - self._singleton_instances[interface] = self._inject_dependencies(dependency_class) + self._singleton_instances[interface] = ( + self._inject_dependencies( + class_=dependency_class, + constructor_args=constructor_args + ) + ) return self._singleton_instances[interface] raise ValueError(f"Invalid dependency scope: {dependency_scope}") - def _inject_dependencies(self, class_, scope_name=None): + def _validate_constructor_args(self, constructor_args: Dict[str, Any], class_: Type) -> None: + class_constructor = inspect.signature(class_.__init__).parameters + + # Check if any required parameter is missing + missing_params = [param for param in class_constructor.keys() if + param not in ["self", "cls", "args", "kwargs"] and + param not in constructor_args] + if missing_params: + raise ValueError( + f"Missing required constructor arguments: " + f"{', '.join(missing_params)} for class '{class_.__name__}'.") + + for arg_name, arg_value in constructor_args.items(): + if arg_name not in class_constructor: + raise ValueError( + f"Invalid constructor argument '{arg_name}' for class '{class_.__name__}'. " + f"The class does not have a constructor parameter with this name.") + + expected_type = class_constructor[arg_name].annotation + if expected_type != inspect.Parameter.empty: + if not isinstance(arg_value, expected_type): + raise TypeError( + f"Constructor argument '{arg_name}' has an incompatible type. " + f"Expected type: {expected_type}, provided type: {type(arg_value)}.") + + def _inject_dependencies(self, class_, scope_name=None, constructor_args=None): constructor = inspect.signature(class_.__init__) params = constructor.parameters @@ -82,6 +121,10 @@ def _inject_dependencies(self, class_, scope_name=None): # **kwargs parameter pass else: - dependencies[param_name] = self.resolve(param_info.annotation, scope_name=scope_name) + # Check if constructor_args has an argument with the same name + if constructor_args and param_name in constructor_args: + dependencies[param_name] = constructor_args[param_name] + else: + dependencies[param_name] = self.resolve(param_info.annotation, scope_name=scope_name) return class_(**dependencies) diff --git a/src/dependency_injection/registration.py b/src/dependency_injection/registration.py index 77a4a9f..6f7422e 100644 --- a/src/dependency_injection/registration.py +++ b/src/dependency_injection/registration.py @@ -1,9 +1,12 @@ +from typing import Any, Dict + from dependency_injection.scope import Scope class Registration(): - def __init__(self, interface, class_, scope: Scope): + def __init__(self, interface, class_, scope: Scope, constructor_args: Dict[str, Any] = None): self.interface = interface self.class_ = class_ self.scope = scope + self.constructor_args = constructor_args or {} diff --git a/tests/unit_test/car.py b/tests/unit_test/car.py deleted file mode 100644 index 860bf4f..0000000 --- a/tests/unit_test/car.py +++ /dev/null @@ -1,5 +0,0 @@ -from unit_test.vehicle import Vehicle - - -class Car(Vehicle): - pass diff --git a/tests/unit_test/container/register/test_register_scoped.py b/tests/unit_test/container/register/test_register_scoped.py index 5c48298..30264f1 100644 --- a/tests/unit_test/container/register/test_register_scoped.py +++ b/tests/unit_test/container/register/test_register_scoped.py @@ -1,9 +1,7 @@ import pytest from dependency_injection.container import DependencyContainer -from unit_test.car import Car from unit_test.unit_test_case import UnitTestCase -from unit_test.vehicle import Vehicle class TestRegisterScoped(UnitTestCase): @@ -12,6 +10,12 @@ def test_register_scoped_succeeds_when_not_previously_registered( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -26,6 +30,12 @@ def test_register_scoped_fails_when_already_registered( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car diff --git a/tests/unit_test/container/register/test_register_singleton.py b/tests/unit_test/container/register/test_register_singleton.py index f051344..bc4da5e 100644 --- a/tests/unit_test/container/register/test_register_singleton.py +++ b/tests/unit_test/container/register/test_register_singleton.py @@ -1,9 +1,7 @@ import pytest from dependency_injection.container import DependencyContainer -from unit_test.car import Car from unit_test.unit_test_case import UnitTestCase -from unit_test.vehicle import Vehicle class TestRegisterSingleton(UnitTestCase): @@ -12,6 +10,12 @@ def test_register_singleton_succeeds_when_not_previously_registered( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -26,6 +30,12 @@ def test_register_singleton_fails_when_already_registered( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car diff --git a/tests/unit_test/container/register/test_register_transient.py b/tests/unit_test/container/register/test_register_transient.py index e5afabc..6a51f25 100644 --- a/tests/unit_test/container/register/test_register_transient.py +++ b/tests/unit_test/container/register/test_register_transient.py @@ -1,9 +1,7 @@ import pytest from dependency_injection.container import DependencyContainer -from unit_test.car import Car from unit_test.unit_test_case import UnitTestCase -from unit_test.vehicle import Vehicle class TestRegisterTransient(UnitTestCase): @@ -12,6 +10,12 @@ def test_register_transient_succeeds_when_not_previously_registered( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -19,13 +23,18 @@ def test_register_transient_succeeds_when_not_previously_registered( # act dependency_container.register_transient(interface, dependency_class) - # assert - # (no exception thrown) + # assert (no exception thrown) def test_register_transient_fails_when_already_registered( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car diff --git a/tests/unit_test/container/register/test_register_with_args.py b/tests/unit_test/container/register/test_register_with_args.py new file mode 100644 index 0000000..0071c2d --- /dev/null +++ b/tests/unit_test/container/register/test_register_with_args.py @@ -0,0 +1,22 @@ +from dependency_injection.container import DependencyContainer +from unit_test.unit_test_case import UnitTestCase + + +class TestRegisterWithArgs(UnitTestCase): + + def test_register_with_constructor_args( + self, + ): + # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + + dependency_container = DependencyContainer.get_instance() + interface = Vehicle + dependency_class = Car + + # act + assert (no exception) + dependency_container.register_transient(interface, dependency_class, constructor_args={"color": "red", "mileage": 3800}) diff --git a/tests/unit_test/container/resolve/test_resolve_scoped.py b/tests/unit_test/container/resolve/test_resolve_scoped.py index 0b098b5..a4026cb 100644 --- a/tests/unit_test/container/resolve/test_resolve_scoped.py +++ b/tests/unit_test/container/resolve/test_resolve_scoped.py @@ -1,7 +1,5 @@ from dependency_injection.container import DependencyContainer -from unit_test.car import Car from unit_test.unit_test_case import UnitTestCase -from unit_test.vehicle import Vehicle class TestResolveScoped(UnitTestCase): @@ -10,6 +8,12 @@ def test_resolve_scoped_in_same_scope_returns_same_instance( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -26,6 +30,12 @@ def test_resolve_scoped_in_different_scopes_returns_different_instances( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car diff --git a/tests/unit_test/container/resolve/test_resolve_singleton.py b/tests/unit_test/container/resolve/test_resolve_singleton.py index b41a967..e672f8b 100644 --- a/tests/unit_test/container/resolve/test_resolve_singleton.py +++ b/tests/unit_test/container/resolve/test_resolve_singleton.py @@ -1,7 +1,5 @@ from dependency_injection.container import DependencyContainer -from unit_test.car import Car from unit_test.unit_test_case import UnitTestCase -from unit_test.vehicle import Vehicle class TestResolveSingleton(UnitTestCase): @@ -10,6 +8,12 @@ def test_resolve_singleton_returns_instance( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -25,6 +29,12 @@ def test_resolve_singleton_twice_returns_same_instance( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer() interface = Vehicle dependency_class = Car diff --git a/tests/unit_test/container/resolve/test_resolve_transient.py b/tests/unit_test/container/resolve/test_resolve_transient.py index ea9e60e..460e839 100644 --- a/tests/unit_test/container/resolve/test_resolve_transient.py +++ b/tests/unit_test/container/resolve/test_resolve_transient.py @@ -1,7 +1,5 @@ from dependency_injection.container import DependencyContainer -from unit_test.car import Car from unit_test.unit_test_case import UnitTestCase -from unit_test.vehicle import Vehicle class TestResolveTransient(UnitTestCase): @@ -10,6 +8,12 @@ def test_resolve_transient_returns_an_instance( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -25,6 +29,12 @@ def test_resolve_transient_twice_returns_different_instances( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer() interface = Vehicle dependency_class = Car @@ -36,4 +46,3 @@ def test_resolve_transient_twice_returns_different_instances( # assert self.assertNotEqual(resolved_dependency_1, resolved_dependency_2) - diff --git a/tests/unit_test/container/resolve/test_resolve_with_args.py b/tests/unit_test/container/resolve/test_resolve_with_args.py new file mode 100644 index 0000000..c50129c --- /dev/null +++ b/tests/unit_test/container/resolve/test_resolve_with_args.py @@ -0,0 +1,135 @@ +import pytest + +from dependency_injection.container import DependencyContainer +from unit_test.unit_test_case import UnitTestCase + + +class TestResolveWithArgs(UnitTestCase): + + def test_resolve_passes_constructor_args( + self, + ): + # arrange + class Vehicle: + pass + + class Car(Vehicle): + def __init__(self, color, make): + self.color = color + self.make = make + + dependency_container = DependencyContainer.get_instance() + interface = Vehicle + dependency_class = Car + dependency_container.register_transient( + interface=interface, + class_=dependency_class, + constructor_args={"color": "red", "make": "Volvo"}) + + # act + resolved_dependency = dependency_container.resolve(interface) + + # assert + self.assertEqual("red", resolved_dependency.color) + self.assertEqual("Volvo", resolved_dependency.make) + + def test_resolve_with_extra_constructor_arg_raises( + self, + ): + # arrange + class Vehicle: + pass + + class Car(Vehicle): + def __init__(self, color: str, make: str): + self.color = color + self.make = make + + dependency_container = DependencyContainer.get_instance() + interface = Vehicle + dependency_class = Car + dependency_container.register_transient( + interface=interface, + class_=dependency_class, + constructor_args={"color": "red", "make": "Volvo", "extra": "argument"}) + + # act + with pytest.raises( + ValueError, + match="Invalid constructor argument 'extra' for class 'Car'. " + "The class does not have a constructor parameter with this name."): + dependency_container.resolve(interface) + + def test_resolve_with_missing_constructor_arg_raises( + self, + ): + # arrange + class Vehicle: + pass + + class Car(Vehicle): + def __init__(self, color: str, make: str): + self.color = color + self.make = make + + dependency_container = DependencyContainer.get_instance() + interface = Vehicle + dependency_class = Car + dependency_container.register_transient( + interface=interface, + class_=dependency_class, + constructor_args={"color": "red"}) + + # act + with pytest.raises(ValueError, match="Missing required constructor arguments: make for class 'Car'."): + dependency_container.resolve(interface) + + def test_resolve_with_wrong_constructor_arg_type_raises( + self, + ): + # arrange + class Vehicle: + pass + + class Car(Vehicle): + def __init__(self, color: str, make: str): + self.color = color + self.make = make + + dependency_container = DependencyContainer.get_instance() + interface = Vehicle + dependency_class = Car + dependency_container.register_transient( + interface=interface, + class_=dependency_class, + constructor_args={"color": "red", "make": -1}) + + # act + with pytest.raises( + TypeError, + match="Constructor argument 'make' has an incompatible type. " + "Expected type: , provided type: ."): + dependency_container.resolve(interface) + + def test_resolve_when_no_constructor_arg_type_is_ok( + self, + ): + # arrange + class Vehicle: + pass + + class Car(Vehicle): + def __init__(self, color: str, make): + self.color = color + self.make = make + + dependency_container = DependencyContainer.get_instance() + interface = Vehicle + dependency_class = Car + dependency_container.register_transient( + interface=interface, + class_=dependency_class, + constructor_args={"color": "red", "make": -1}) + + # act + assert (no exception) + dependency_container.resolve(interface) diff --git a/tests/unit_test/decorator/test_decorator.py b/tests/unit_test/decorator/test_decorator.py index 6c2c651..209410e 100644 --- a/tests/unit_test/decorator/test_decorator.py +++ b/tests/unit_test/decorator/test_decorator.py @@ -2,9 +2,7 @@ from dependency_injection.container import DependencyContainer from dependency_injection.decorator import inject -from unit_test.car import Car from unit_test.unit_test_case import UnitTestCase -from unit_test.vehicle import Vehicle class TestDecorator(UnitTestCase): @@ -12,6 +10,12 @@ class TestDecorator(UnitTestCase): def test_decoration_on_class_method(self): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -35,6 +39,12 @@ def park(cls, vehicle: Vehicle): def test_decoration_on_static_method(self): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -59,6 +69,12 @@ def test_decoration_on_instance_method_raises( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + dependency_container = DependencyContainer.get_instance() interface = Vehicle dependency_class = Car @@ -75,6 +91,12 @@ def test_class_method_decorator_container_name_is_honoured( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + interface = Vehicle dependency_class = Car @@ -104,6 +126,12 @@ def test_class_method_decorator_scope_name_is_honoured( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + interface = Vehicle dependency_class = Car @@ -140,6 +168,12 @@ def test_static_method_decorator_container_name_is_honoured( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + interface = Vehicle dependency_class = Car @@ -169,6 +203,12 @@ def test_static_method_decorator_scope_name_is_honoured( self, ): # arrange + class Vehicle: + pass + + class Car(Vehicle): + pass + interface = Vehicle dependency_class = Car diff --git a/tests/unit_test/vehicle.py b/tests/unit_test/vehicle.py deleted file mode 100644 index 9a0bd64..0000000 --- a/tests/unit_test/vehicle.py +++ /dev/null @@ -1,2 +0,0 @@ -class Vehicle: - pass From a0834e0836faa5ee4567091c3a80c1c93e0527d9 Mon Sep 17 00:00:00 2001 From: David Runemalm Date: Sat, 2 Mar 2024 18:47:10 +0700 Subject: [PATCH 2/3] Add example to README. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index afc60e8..8247fc6 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,13 @@ third_container = DependencyContainer.get_instance(name="third_container") dependency_container.register_transient(SomeInterface, SomeClass) dependency_container.register_scoped(AnotherInterface, AnotherClass) dependency_container.register_singleton(ThirdInterface, ThirdClass) + +# Registering dependencies with constructor arguments +dependency_container.register_transient( + SomeInterface, + SomeClass, + constructor_args={"arg1": value1, "arg2": value2} +) ``` ### Resolving dependencies using the container From 0f4a6b24a2eaf5c44d88591e661ecbad5f195497 Mon Sep 17 00:00:00 2001 From: David Runemalm Date: Sat, 2 Mar 2024 18:47:22 +0700 Subject: [PATCH 3/3] Bump version to v1.0.0-alpha.4. --- docs/conf.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index a722236..a9f0941 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,7 +34,7 @@ version = '1.0' # The full version, including alpha/beta/rc tags -release = '1.0.0-alpha.3' +release = '1.0.0-alpha.4' # -- General configuration --------------------------------------------------- diff --git a/setup.py b/setup.py index 2c90e70..6d1a3b7 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='py-dependency-injection', - version='1.0.0-alpha.3', + version='1.0.0-alpha.4', author='David Runemalm, 2024', author_email='david.runemalm@gmail.com', description=