From 1b7c33d0b63406870008c4d4a0ab5b52278a517d Mon Sep 17 00:00:00 2001 From: tarsil Date: Tue, 3 Oct 2023 21:14:35 +0100 Subject: [PATCH 1/2] Allow Factory to import via string --- docker-compose.yml | 4 +- esmerald/core/di/__init__.py | 0 esmerald/core/di/provider.py | 89 ++++++++++++++++++++++++++++++++++++ esmerald/injector.py | 27 +++++++++-- tests/test_inject.py | 23 ++++++++++ tests/test_injects.py | 51 +++++++++++++++++++++ 6 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 esmerald/core/di/__init__.py create mode 100644 esmerald/core/di/provider.py diff --git a/docker-compose.yml b/docker-compose.yml index a79a3919..f85e2f47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: MONGO_INITDB_ROOT_PASSWORD: mongoadmin MONGO_INITDB_DATABASE: mongodb volumes: - - "mongo_db_data:/data/db" + - "mongo_esmerald_db_data:/data/db" expose: - 27017 ports: @@ -56,5 +56,5 @@ services: volumes: esmerald: external: true - mongo_db_data: + mongo_esmerald_db_data: external: true diff --git a/esmerald/core/di/__init__.py b/esmerald/core/di/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esmerald/core/di/provider.py b/esmerald/core/di/provider.py new file mode 100644 index 00000000..e691819a --- /dev/null +++ b/esmerald/core/di/provider.py @@ -0,0 +1,89 @@ +""" +Functions to use with the Factory dependency injection. +""" +from typing import Any, Callable, Tuple, cast + +from esmerald.exceptions import ImproperlyConfigured +from esmerald.utils.module_loading import import_string + + +def _lookup(klass: Any, comp: Any, import_path: Any) -> Any: + """ + Runs a lookup via __import__ and returns the component. + """ + try: + return getattr(klass, comp) + except AttributeError: + __import__(import_path) + return getattr(klass, comp) + + +def _importer(target: Any, attribute: Any) -> Any: + """ + Gets the attribute from the target. + """ + components = target.split(".") + import_path = components.pop(0) + klass = __import__(import_path) + + for comp in components: + import_path += ".%s" % comp + klass = _lookup(klass, comp, import_path) + return getattr(klass, attribute) + + +def _get_provider_callable(target: str) -> Any: + try: + target, attribute = target.rsplit(".", 1) + except (TypeError, ValueError, AttributeError): + raise TypeError(f"Need a valid target to lookup. You supplied: {target!r}") from None + + def getter() -> Any: + return _importer(target, attribute) + + return getter + + +def load_provider(provider: str) -> Tuple[Callable, bool]: + """ + Loads any callable by string import. This will make + sure that there is no need to have all the imports in one + file to use the `esmerald.injector.Factory`. + + Example: + # myapp.daos.py + from esmerald import AsyncDAOProtocol + + + class MyDAO(AsyncDAOProtocol): + ... + + # myapp.urls.py + from esmerald import Inject, Factory, Gateway + + route_patterns = [ + Gateway( + ..., + dependencies={"my_dao": Inject(Factory("myapp.daos.MyDAO"))} + ) + ] + """ + if not isinstance(provider, str): + raise ImproperlyConfigured( + "The `provider` should be a string with the format ." + ) + + is_nested: bool = False + try: + provider_callable = import_string(provider) + except ModuleNotFoundError: + target = _get_provider_callable(provider) + provider_callable = target + is_nested = True + + if not callable(provider_callable): + raise ImproperlyConfigured( + f"The `provider` specified must be a callable, got {type(provider_callable)} instead." + ) + + return cast(Callable, provider_callable), is_nested diff --git a/esmerald/injector.py b/esmerald/injector.py index 6208bd66..a57c81a8 100644 --- a/esmerald/injector.py +++ b/esmerald/injector.py @@ -1,5 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union +from esmerald.core.di.provider import load_provider from esmerald.parsers import ArbitraryHashableBaseModel from esmerald.transformers.datastructures import Signature from esmerald.typing import Void @@ -10,10 +11,19 @@ class Factory: - def __init__(self, provides: "AnyCallable", *args: Any) -> None: - self.provides = provides + def __init__(self, provides: Union["AnyCallable", str], *args: Any) -> None: + """ + The provider can be passed in separate ways. Via direct callable + or via string value where it will be automatically imported by the application. + """ self.__args: Tuple[Any, ...] = () self.set_args(*args) + self.is_nested: bool = False + + if isinstance(provides, str): + self.provides, self.is_nested = load_provider(provides) + else: + self.provides = provides def set_args(self, *args: Any) -> None: self.__args = args @@ -24,6 +34,17 @@ def cls(self) -> "AnyCallable": return self.provides async def __call__(self) -> Any: + """ + This handles with normal and nested imports. + + Example: + + 1. MyClass.func + 2. MyClass.AnotherClass.func + """ + if self.is_nested: + self.provides = self.provides() + if is_async_callable(self.provides): value = await self.provides(*self.__args) else: diff --git a/tests/test_inject.py b/tests/test_inject.py index 38cc5b6e..a177c8dd 100644 --- a/tests/test_inject.py +++ b/tests/test_inject.py @@ -19,6 +19,20 @@ def __init__(self) -> None: async def async_class(cls) -> int: return cls.val + class InsideTest: + val = 56 + + @classmethod + async def async_class(cls) -> int: + return cls.val + + class NestedInsideTest: + val = 92 + + @classmethod + async def async_class(cls) -> int: + return cls.val + @classmethod def sync_class(cls) -> int: return cls.val @@ -59,6 +73,7 @@ def sync_fn(val: str = "three-one") -> str: [ (async_fn, "three-one"), (Factory(async_fn), "three-one"), + (Factory("tests.test_inject.async_fn"), "three-one"), ], ) @pytest.mark.asyncio() @@ -73,6 +88,7 @@ async def test_Inject_default(_callable, exp) -> None: [ (async_fn, "three-one"), (Factory(async_fn), "three-one"), + (Factory("tests.test_inject.async_fn"), "three-one"), ], ) @pytest.mark.asyncio() @@ -130,6 +146,13 @@ def test_Injectr_equality_check_Factory() -> None: (Factory(Test.sync_static), "one-three"), (Factory(Test().async_instance), 13), (Factory(Test().sync_instance), 13), + (Factory("tests.test_inject.async_fn"), "three-one"), + (Factory("tests.test_inject.Test.async_class"), 31), + (Factory("tests.test_inject.Test.sync_class"), 31), + (Factory("tests.test_inject.Test.async_static"), "one-three"), + (Factory("tests.test_inject.Test.sync_static"), "one-three"), + (Factory("tests.test_inject.Test.InsideTest.async_class"), 56), + (Factory("tests.test_inject.Test.InsideTest.NestedInsideTest.async_class"), 92), ], ) @pytest.mark.asyncio() diff --git a/tests/test_injects.py b/tests/test_injects.py index 256b1d17..73e4945a 100644 --- a/tests/test_injects.py +++ b/tests/test_injects.py @@ -139,6 +139,17 @@ async def test(fake_dao: FakeDAO = Injects()) -> Dict[str, int]: assert resp.json() == {"value": ["awesome_data"]} +def test_no_default_dependency_Injected_with_Factory_from_string() -> None: + @get(dependencies={"fake_dao": Inject(Factory("tests.conftest.FakeDAO"))}) + async def test(fake_dao: FakeDAO = Injects()) -> Dict[str, int]: + result = await fake_dao.get_all() + return {"value": result} + + with create_client(routes=[Gateway(handler=test)]) as client: + resp = client.get("/") + assert resp.json() == {"value": ["awesome_data"]} + + def test_dependency_not_Injected_and_no_default() -> None: @get() def test(value: int = Injects()) -> Dict[str, int]: @@ -177,6 +188,21 @@ async def test(self, fake_dao: FakeDAO = Injects()) -> Dict[str, List[str]]: assert resp.json() == {"value": ["awesome_data"]} +def test_dependency_Injected_on_APIView_with_Factory_from_string() -> None: + class C(APIView): + path = "" + dependencies = {"fake_dao": Inject(Factory("tests.conftest.FakeDAO"))} + + @get() + async def test(self, fake_dao: FakeDAO = Injects()) -> Dict[str, List[str]]: + result = await fake_dao.get_all() + return {"value": result} + + with create_client(routes=[Gateway(handler=C)]) as client: + resp = client.get("/") + assert resp.json() == {"value": ["awesome_data"]} + + def test_dependency_skip_validation() -> None: @get("/validated") def validated(value: int = Injects()) -> Dict[str, int]: @@ -224,3 +250,28 @@ async def skipped(fake_dao: FakeDAO = Injects(skip_validation=True)) -> Dict[str skipped_resp = client.get("/skipped") assert skipped_resp.status_code == HTTP_200_OK assert skipped_resp.json() == {"value": ["awesome_data"]} + + +def test_dependency_skip_validation_with_Factory_from_string() -> None: + @get("/validated") + def validated(fake_dao: int = Injects()) -> Dict[str, List[str]]: + """ """ + + @get("/skipped") + async def skipped(fake_dao: FakeDAO = Injects(skip_validation=True)) -> Dict[str, List[str]]: + result = await fake_dao.get_all() + return {"value": result} + + with create_client( + routes=[ + Gateway(handler=validated), + Gateway(handler=skipped), + ], + dependencies={"fake_dao": Inject(Factory("tests.conftest.FakeDAO"))}, + ) as client: + validated_resp = client.get("/validated") + assert validated_resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR + + skipped_resp = client.get("/skipped") + assert skipped_resp.status_code == HTTP_200_OK + assert skipped_resp.json() == {"value": ["awesome_data"]} From ab26f594af4bf65f3923c573362417f83fc9ae8e Mon Sep 17 00:00:00 2001 From: tarsil Date: Tue, 3 Oct 2023 21:27:36 +0100 Subject: [PATCH 2/2] Add documentation about the Factory import --- docs/dependencies.md | 42 ++++++++++++++++++-- docs_src/dependencies/urls_factory_import.py | 18 +++++++++ mkdocs.yml | 2 +- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 docs_src/dependencies/urls_factory_import.py diff --git a/docs/dependencies.md b/docs/dependencies.md index a3e06709..1c6093f6 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -49,7 +49,7 @@ and checks if the value is bigger or equal than 5 and that result `is_valid` is The same is applied also to [exception handlers](./exception-handlers.md). -## More Real World example +## More real world examples Now let us imagine that we have a web application with one of the views. Something like this: @@ -57,9 +57,9 @@ Now let us imagine that we have a web application with one of the views. Somethi {!> ../docs_src/dependencies/views.py !} ``` -As you can notice, the user_dao is injected automatically using the appropriate level of dependency injection.. +As you can notice, the `user_dao`` is injected automatically using the appropriate level of dependency injection. -Let's see the `urls.py` and understand from where we got the `user_dao`: +Let us see the `urls.py` and understand from where we got the `user_dao`: ```python hl_lines="14-16 32-34" {!> ../docs_src/dependencies/urls.py !} @@ -85,5 +85,41 @@ The Factory is a clean wrapper around any callable (classes usually are callable No need to explicitly instantiate the class, just pass the class definition to the `Factory` and Esmerald takes care of the rest for you. +### Importing using strings + +Like everything is Esmerald, there are different ways of achieving the same results and the `Factory` +is no exception. + +In the previous examples we were passing the `UserDAO`, `ArticleDAO` and `PostDAO` classes directly +into the `Factory` object and that also means that **you will need to import the objects to then be passed**. + +What can happen with this process? Majority of the times nothing **but** you can also have the classic +`partially imported ...` annoying error, right? + +Well, the good news is that Esmerald got you covered, as usual. + +The `Factory` **also allows import via string** without the need of importing directly the object +to the place where it is needed. + +Let us then see how it would look like and let us then assume: + +1. The `UserDAO` is located somewhere in the codebase like `myapp.accounts.daos`. +2. The `ArticleDAO` is located somewhere in the codebase like `myapp.articles.daos`. +3. The `PostDAO` is located somewhere in the codebase like `myapp.posts.daos`. + +Ok, now that we know this, let us see how it would look like in the codebase importing it inside the +`Factory`. + +```python hl_lines="13-15" +{!> ../docs_src/dependencies/urls_factory_import.py !} +``` + +Now, this is a beauty is it not? This way, the codebase is cleaner and without all of those imported +objects from the top. + +!!! Tip + Both cases work well within Esmerald, this is simply an alternative in case the complexity of + the codebase increases and you would like to tidy it up a bit more. + In conclusion, if your views/routes expect dependencies, you can define them in the upper level as described and Esmerald will make sure that they will be automatically injected. diff --git a/docs_src/dependencies/urls_factory_import.py b/docs_src/dependencies/urls_factory_import.py new file mode 100644 index 00000000..fb17fce7 --- /dev/null +++ b/docs_src/dependencies/urls_factory_import.py @@ -0,0 +1,18 @@ +from esmerald import Factory, Include, Inject + +route_patterns = [ + Include( + "/api/v1", + routes=[ + Include("/accounts", namespace="accounts.v1.urls"), + Include("/articles", namespace="articles.v1.urls"), + Include("/posts", namespace="posts.v1.urls"), + ], + interceptors=[LoggingInterceptor], # Custom interceptor + dependencies={ + "user_dao": Inject(Factory("myapp.accounts.daos.UserDAO")), + "article_dao": Inject(Factory("myapp.articles.daos.ArticleDAO")), + "post_dao": Inject(Factory("myapp.posts.daos.PostDAO")), + }, + ) +] diff --git a/mkdocs.yml b/mkdocs.yml index 6e4b8f9a..09859fd9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,9 +83,9 @@ nav: - Interceptors: "interceptors.md" - Permissions: "permissions.md" - Middleware: "middleware/middleware.md" + - Dependencies: "dependencies.md" - Exceptions: "exceptions.md" - Exception Handlers: "exception-handlers.md" - - Dependencies: "dependencies.md" - Pluggables: "pluggables.md" - Datastructures: "datastructures.md" - Password Hashers: "password-hashers.md"