diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2aed4dd..da86b7c 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -3,10 +3,15 @@ nav: - Home: index.md - Introduction: introduction.md - Advanced Topics: + - Factory functions: factory_functions.md - Working with Interfaces: interfaces.md - Manual configuration: manual_configuration.md - Multiple containers: multiple_containers.md + - Misc: + - Introduce to an existing project: introduce_to_an_existing_project.md - Examples: - Fastapi: examples/fastapi.md theme: readthedocs +markdown_extensions: + - admonition docs_dir: pages \ No newline at end of file diff --git a/docs/pages/factory_functions.md b/docs/pages/factory_functions.md new file mode 100644 index 0000000..94f5331 --- /dev/null +++ b/docs/pages/factory_functions.md @@ -0,0 +1,65 @@ +# Factory functions + +Factory functions allow the container to wire dependencies that require additional logic to create +or be able to inject objects it doesn't own. + +Typically getting the necessary dependencies is enough to construct an object. However, there are scenarios +where you need to delegate the creation of an object to a special function called a +[factory](https://en.wikipedia.org/wiki/Factory_(object-oriented_programming)). + +## Use cases + +Some of the use cases for factories are as follows: + +* Object construction needs additional logic or configuration. +* Depending on the runtime environment or configuration, you may need to create different objects +inheriting from the same base (See [Strategy Pattern](https://en.wikipedia.org/wiki/Strategy_pattern)) or configure them differently. +* Gradually introduce DI into an existing project where the container should be able to inject dependencies created elsewhere. +Such as injecting the same databaser connection as the rest of the application. +* Eliminate services which have only one method that returns the same object and instead inject the object directly. + * A service that returns a db connection + * A service which returns the current authenticated user + * Register the result of a service's method as its own service. Instead of calling `auth_service.get_current_user()` every time, inject the authenticated user directly. + +## Usage + +In order for the container to be able to inject these dependencies you must register the factory function. +You can do this by using the `@container.register` decorator or by calling `container.register(fn)` directly. + +When the container needs to inject a dependency it checks known factories to see if any of them can create it. + + +!!! info + The return type of the function tells the container what type of dependency it can create. + +!!! warning + Factories can only depend on objects known by the container! + +## Examples + +Assume in the context of a web application a class `User` exists and represents a user of the system. + +```python +# Instead of doing the following over and over again +def get_user_logs(auth_service: AuthService): + current_user = auth_service.get_current_user() + ... + + + +# You can create a factory and inject the authenticated user directly. +# You may want to create a new type to make a disctinction on the type of user this is. +AuthenticatedUser = User + +container.register +def get_current_user(auth_service: AuthService) -> AuthenticatedUser: + return auth_service.get_current_user() + +# Now it is possible to inject the authenticated user directly wherever it is necessary. +def get_user_logs(user: AuthenticatedUser): + ... +``` + +## Links + +* [Introduce to an existing project](introduce_to_an_existing_project.md) diff --git a/docs/pages/index.md b/docs/pages/index.md index e7d3a66..868cc62 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -4,7 +4,7 @@ Dependency and configuration injection library in Python. **1. Set application configuration parameters** ```python container.params.update({ - "db.connection_str": "sqlite://memory", + "db.connection_str": "sqlite://", "auth.user": os.environ.get("USER"), "cache_dir": "/var/cache/", "env": os.environ.get("ENV", "dev") diff --git a/docs/pages/introduce_to_an_existing_project.md b/docs/pages/introduce_to_an_existing_project.md new file mode 100644 index 0000000..f7a8990 --- /dev/null +++ b/docs/pages/introduce_to_an_existing_project.md @@ -0,0 +1,51 @@ +# Introduce to an existing project + +It can be challenging to add DI to an existing project which doesn't yet use it. One of the issues you will run into +sooner or later is being able to share resources between code which uses DI and the rest of the application +which does not. + +This is especially useful to allow the container to inject dependencies created elsewhere. + +Think of a database connection. Your application probably already has one. Instead of opening a second connection +using a new database service, you can instruct the container how to get the connection either via a service or +by using factory functions. + +Another case might be an existing service that is already constructed, and you wish to be able to inject. + +## Using a Service + +A typical way to solve this would involve create a service with a single method +that uses existing functionality to get the desired object and simply returns it. + +```python +# Example of a service acting as a factory +@container.register +@dataclass +class DbConnectionService: + self.conn = get_db_connection_from_somewhere() +``` + +Here, it is possible inject `DbConnectionService` and call `.conn` to get the connection. While this works, it's not the best way to go. + +## Using Factory functions + +To handle this more elegantly, WireUp lets you register functions as factories. +You can do this by using the `@container.register` decorator or by calling `container.register(fn)` directly. + + +```python +@container.register +def get_db_connection_from_somewhere() -> Connection: + return ... + +# Alternatively + +container.register(get_db_connection_from_somewhere) +``` + +Now it is possible to inject `Connection` just like any other dependency. + + +## Links + +* [Factory functions](factory_functions.md) \ No newline at end of file diff --git a/readme.md b/readme.md index b5264c5..bc3cd32 100644 --- a/readme.md +++ b/readme.md @@ -6,7 +6,7 @@ Effortless dependency injection in Python. **1. Set application parameters** ```python container.params.update({ - "db.connection_str": "sqlite://memory", + "db.connection_str": "sqlite://", "auth.user": os.environ.get("USER"), "cache_dir": "/var/cache/", "env": os.environ.get("ENV", "dev") diff --git a/test/test_container_static_factory.py b/test/test_container_static_factory.py new file mode 100644 index 0000000..443d883 --- /dev/null +++ b/test/test_container_static_factory.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from unittest import TestCase + +from test.fixtures import Counter, FooBar +from test.services.random_service import RandomService +from wireup import DependencyContainer, ParameterBag, wire + + +class ThingToBeCreated: + def __init__(self, val: str): + self.val = val + + +class TestContainerStaticFactory(TestCase): + def setUp(self) -> None: + self.container = DependencyContainer(ParameterBag()) + + def test_injects_using_factory_with_dependencies(self): + self.container.register(RandomService) + self.container.params.put("dummy_val", "foo") + + @self.container.register + def create_thing(val=wire(param="dummy_val")) -> ThingToBeCreated: + return ThingToBeCreated(val=val) + + @self.container.autowire + def inner(thing: ThingToBeCreated): + self.assertEqual(thing.val, "foo") + + inner() + + self.assertEqual(create_thing("new").val, "new", msg="Assert fn is not modified") + + def test_injects_using_factory_returns_singletons(self): + self.container.params.put("start", 5) + + @self.container.register + def create_thing(start=wire(param="start")) -> Counter: + return Counter(count=start) + + @self.container.autowire + def inner(c1: Counter, c2: Counter): + c1.inc() + + self.assertEqual(c1.count, 6) + self.assertEqual(c1.count, c2.count) + + inner() + + def test_injects_on_instance_methods(self): + this = self + + class Dummy: + @self.container.autowire + def inner(self, c1: Counter): + c1.inc() + this.assertEqual(c1.count, 1) + + @self.container.register + def create_thing() -> Counter: + return Counter() + + Dummy().inner() + + def test_register_known_container_type(self): + self.container.register(RandomService) + + with self.assertRaises(ValueError) as context: + + @self.container.register + def create_random_service() -> RandomService: + return RandomService() + + self.assertEqual( + f"Cannot register factory function as type {RandomService} is already known by the container.", + str(context.exception), + ) + + def test_register_factory_known_type_from_other_factory(self): + with self.assertRaises(ValueError) as context: + + @self.container.register + def create_random_service() -> RandomService: + return RandomService() + + @self.container.register + def create_random_service_too() -> RandomService: + return RandomService() + + self.assertEqual( + f"A function is already registered as a factory for dependency type {RandomService}.", + str(context.exception), + ) + + def test_register_factory_no_return_type(self): + with self.assertRaises(ValueError) as context: + + @self.container.register + def create_random_service(): + return RandomService() + + self.assertEqual( + "Factory functions must specify a return type denoting the type of dependency it can create.", + str(context.exception), + ) + + def test_factory_as_property_accessor(self): + @self.container.register + class FooGenerator: + def get_foo(self) -> FooBar: + return FooBar() + + @self.container.autowire + def inner(foobar: FooBar): + self.assertEqual(foobar.foo, "bar") + + @self.container.register + def foo_factory(foo_gen: FooGenerator) -> FooBar: + return foo_gen.get_foo() + + inner() diff --git a/wireup/ioc/dependency_container.py b/wireup/ioc/dependency_container.py index b22a9c3..82a01ea 100644 --- a/wireup/ioc/dependency_container.py +++ b/wireup/ioc/dependency_container.py @@ -46,6 +46,7 @@ def __init__(self, parameter_bag: ParameterBag) -> None: self.__known_interfaces: dict[type[__T], dict[str, type[__T]]] = {} self.__known_impls: dict[type[__T], set[str]] = defaultdict(set) self.__initialized_objects: dict[_ContainerObjectIdentifier, object] = {} + self.__factory_functions: dict[type[__T], Callable[..., __T]] = {} self.params: ParameterBag = parameter_bag self.initialization_context = DependencyInitializationContext() @@ -53,7 +54,7 @@ def get(self, klass: type[__T], qualifier: ContainerProxyQualifierValue = None) """Get an instance of the requested type. Returns an existing initialized instance when possible. :param qualifier: Qualifier for the class if it was registered with one. - :param klass: Class of the component already registered in the container. + :param klass: Class of the dependency already registered in the container. :return: """ self.__assert_dependency_exists(klass, qualifier) @@ -69,24 +70,37 @@ def abstract(self, klass: type[__T]) -> type[__T]: return klass - def register(self, klass: type[__T] | None = None, *, qualifier: ContainerProxyQualifierValue = None) -> type[__T]: - """Register a component in the container. + def register( + self, + obj: type[__T] | Callable | None = None, + *, + qualifier: ContainerProxyQualifierValue = None, + ) -> type[__T]: + """Register a dependency in the container. Use @register without parameters on a class or with a single parameter @register(qualifier=name) to register this with a given name when there are multiple implementations of the interface this implements. + Use @register on a function to register that function as a factory method which produces an object + that matches its return type. Use this for objects that the container does not own but should be able to build. + The container stores all necessary metadata for this class and the underlying class remains unmodified. """ # Allow register to be used either with or without arguments - if klass is None: + if obj is None: def decorated(inner_class: type[__T]) -> type[__T]: return self.__register_inner(inner_class, qualifier) return decorated - return self.__register_inner(klass, qualifier) + if inspect.isclass(obj): + return self.__register_inner(obj, qualifier) + + self.__register_factory_inner(obj) + + return obj def autowire(self, fn: Callable) -> Callable: """Automatically inject resources from the container to the decorated methods. @@ -115,6 +129,23 @@ def sync_inner(*args: Any, **kwargs: Any) -> Any: return sync_inner + def __register_factory_inner(self, fn: Callable[[], __T]) -> None: + return_type = inspect.signature(fn).return_annotation + + if return_type is Parameter.empty: + msg = "Factory functions must specify a return type denoting the type of dependency it can create." + raise ValueError(msg) + + if self.__is_impl_known_from_factory(return_type): + msg = f"A function is already registered as a factory for dependency type {return_type}." + raise ValueError(msg) + + if self.__is_impl_known(return_type): + msg = f"Cannot register factory function as type {return_type} is already known by the container." + raise ValueError(msg) + + self.__factory_functions[return_type] = fn + def register_all_in_module(self, module: ModuleType, pattern: str = "*") -> None: """Register all modules inside a given package. @@ -164,18 +195,23 @@ def __callable_get_params_to_inject(self, fn: Callable[..., Any], klass: type[__ return {**params_from_context, **values_from_parameters} def __get(self, klass: type[__T], qualifier: ContainerProxyQualifierValue) -> __T: + """Create the real instances of dependencies. Additional dependencies they may have will be lazily created.""" object_type_id = _ContainerObjectIdentifier(klass, qualifier) if object_type_id in self.__initialized_objects: return self.__initialized_objects[object_type_id] self.__assert_dependency_exists(klass, qualifier) - class_to_initialize = klass if klass in self.__known_interfaces: # noqa: SIM102 if concrete_class := self.__get_concrete_class_from_interface_and_qualifier(klass, qualifier): class_to_initialize = concrete_class - instance = class_to_initialize(**self.__callable_get_params_to_inject(klass.__init__, class_to_initialize)) + if self.__is_impl_known_from_factory(class_to_initialize): + fn = self.__factory_functions[class_to_initialize] + instance = fn(**self.__callable_get_params_to_inject(fn)) + else: + instance = class_to_initialize(**self.__callable_get_params_to_inject(klass.__init__, class_to_initialize)) + self.__initialized_objects[_ContainerObjectIdentifier(class_to_initialize, qualifier)] = instance return instance @@ -195,6 +231,9 @@ def __initialize_container_proxy_object_from_parameter(self, parameter: Paramete default_val = parameter.default annotated_type = parameter.annotation + if self.__is_impl_known_from_factory(annotated_type): + return self.__get_proxy_object(annotated_type, None) + qualifier_value = default_val.qualifier if isinstance(default_val, ContainerProxyQualifier) else None if self.__is_interface_known(annotated_type): @@ -249,14 +288,18 @@ def __is_impl_known(self, klass: type[__T]) -> bool: def __is_interface_known(self, klass: type[__T]) -> bool: return klass in self.__known_interfaces + def __is_impl_known_from_factory(self, klass: type[__T]) -> bool: + return klass in self.__factory_functions + def __is_impl_with_qualifier_known(self, klass: type[__T], qualifier_value: ContainerProxyQualifierValue) -> bool: return klass in self.__known_impls and qualifier_value in self.__known_impls[klass] def __is_dependency_known(self, klass: type[__T], qualifier: ContainerProxyQualifierValue) -> bool: is_known_impl = self.__is_impl_with_qualifier_known(klass, qualifier) is_known_intf = self.__is_interface_known_with_valid_qualifier(klass, qualifier) + is_known_from_factory = self.__is_impl_known_from_factory(klass) - return is_known_impl or is_known_intf + return is_known_impl or is_known_intf or is_known_from_factory def __assert_dependency_exists(self, klass: type[__T], qualifier: ContainerProxyQualifierValue) -> None: """Assert that there exists an impl with that qualifier or an interface with an impl and the same qualifier."""