From 52bff81d0d8cac3ebff343c2a12e4d86e850f33e Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Fri, 22 Sep 2023 20:37:28 +0100 Subject: [PATCH 1/7] Update test-suite.yml --- .github/workflows/test-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 5f047d56..d008709e 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -6,7 +6,7 @@ on: branches: - "**" pull_request: - branches: ["main"] + branches: ["main", "v2"] schedule: - cron: "0 0 * * *" From 4cb4b19aaa8d87cf464faecd1a5a2a8bd5fb3379 Mon Sep 17 00:00:00 2001 From: Shako Rzayev Date: Thu, 21 Sep 2023 00:00:14 +0200 Subject: [PATCH 2/7] feat: naive Factory implementation for getting rid of lambdas from Inject --- esmerald/__init__.py | 3 +- esmerald/injector.py | 24 ++++++++++- tests/test_inject.py | 99 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/esmerald/__init__.py b/esmerald/__init__.py index ee652a5d..0f9c1aee 100644 --- a/esmerald/__init__.py +++ b/esmerald/__init__.py @@ -5,7 +5,7 @@ from esmerald.conf import settings from esmerald.conf.global_settings import EsmeraldAPISettings -from esmerald.injector import Inject +from esmerald.injector import Inject, Factory from .applications import ChildEsmerald, Esmerald from .backgound import BackgroundTask, BackgroundTasks @@ -72,6 +72,7 @@ "HTTPException", "Include", "Inject", + "Factory", "Injects", "ImproperlyConfigured", "JSON", diff --git a/esmerald/injector.py b/esmerald/injector.py index 715c9faa..6208bd66 100644 --- a/esmerald/injector.py +++ b/esmerald/injector.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional, Type +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type from esmerald.parsers import ArbitraryHashableBaseModel from esmerald.transformers.datastructures import Signature @@ -9,6 +9,28 @@ from esmerald.typing import AnyCallable +class Factory: + def __init__(self, provides: "AnyCallable", *args: Any) -> None: + self.provides = provides + self.__args: Tuple[Any, ...] = () + self.set_args(*args) + + def set_args(self, *args: Any) -> None: + self.__args = args + + @property + def cls(self) -> "AnyCallable": + """Return provided type.""" + return self.provides + + async def __call__(self) -> Any: + if is_async_callable(self.provides): + value = await self.provides(*self.__args) + else: + value = self.provides(*self.__args) + return value + + class Inject(ArbitraryHashableBaseModel): def __init__(self, dependency: "AnyCallable", use_cache: bool = False, **kwargs: Any): super().__init__(**kwargs) diff --git a/tests/test_inject.py b/tests/test_inject.py index 7b930910..ad8cd8b5 100644 --- a/tests/test_inject.py +++ b/tests/test_inject.py @@ -1,12 +1,23 @@ from functools import partial -from typing import Any +from typing import Any, List import pytest -from esmerald.injector import Inject +from esmerald import AsyncDAOProtocol +from esmerald.injector import Factory, Inject from esmerald.typing import Void +class FakeDAO(AsyncDAOProtocol): + model = "Awesome" + + def __init__(self, conn: str = "awesome_conn"): + self.conn = conn + + async def get_all(self, **kwargs: Any) -> List[Any]: + return ["awesome_data"] + + class Test: __test__ = False @@ -49,20 +60,38 @@ def sync_fn(val: str = "three-one") -> str: async_partial = partial(async_fn, "why-three-and-one") sync_partial = partial(sync_fn, "why-three-and-one") +async_factory = Factory(async_fn, "why-three-and-one") +sync_factory = Factory(sync_fn, "why-three-and-one") +test_factory = Factory(Test) + +@pytest.mark.parametrize( + "_callable,exp", + [ + (async_fn, "three-one"), + (Factory(async_fn), "three-one"), + ], +) @pytest.mark.asyncio() -async def test_Inject_default() -> None: - Injectr = Inject(dependency=async_fn) +async def test_Inject_default(_callable, exp) -> None: + Injectr = Inject(dependency=_callable) value = await Injectr() - assert value == "three-one" + assert value == exp +@pytest.mark.parametrize( + "_callable,exp", + [ + (async_fn, "three-one"), + (Factory(async_fn), "three-one"), + ], +) @pytest.mark.asyncio() -async def test_Inject_cached() -> None: - Injectr = Inject(dependency=async_fn, use_cache=True) +async def test_Inject_cached(_callable, exp) -> None: + Injectr = Inject(dependency=_callable, use_cache=True) assert Injectr.value is Void value = await Injectr() - assert value == "three-one" + assert value == exp assert Injectr.value == value second_value = await Injectr() assert value == second_value @@ -80,6 +109,17 @@ def test_Injectr_equality_check() -> None: assert first_Injectr != second_Injectr +def test_Injectr_equality_check_Factory() -> None: + first_Injectr = Inject(dependency=Factory(sync_fn)) + second_Injectr = Inject(dependency=Factory(sync_fn)) + # The Factory is intended to return different objects, that's why 2 same Factory Injects are different + assert first_Injectr != second_Injectr + third_Injectr = Inject(dependency=Factory(sync_fn), use_cache=True) + assert first_Injectr != third_Injectr + second_Injectr.value = True + assert first_Injectr != second_Injectr + + @pytest.mark.parametrize( "fn, exp", [ @@ -93,8 +133,51 @@ def test_Injectr_equality_check() -> None: (sync_fn, "three-one"), (async_partial, "why-three-and-one"), (sync_partial, "why-three-and-one"), + (async_factory, "why-three-and-one"), + (sync_factory, "why-three-and-one"), + (Factory(Test.async_class), 31), + (Factory(Test.sync_class), 31), + (Factory(Test.async_static), "one-three"), + (Factory(Test.sync_static), "one-three"), + (Factory(Test().async_instance), 13), + (Factory(Test().sync_instance), 13), ], ) @pytest.mark.asyncio() async def test_Inject_for_callable(fn: Any, exp: Any) -> None: assert await Inject(fn)() == exp + + +@pytest.mark.asyncio() +async def test_if_DAO_is_injectable() -> None: + """ + Current: + dependencies={ + "fake_dao": Inject(lambda: FakeDAO()), + }, + + dependencies={ + "fake_dao": Inject(lambda: FakeDAO(conn="nice_conn")), + }, + + Alternative: + dependencies={ + "fake_dao": Inject(Factory(FakeDAO)), + }, + dependencies={ + "fake_dao": Inject(Factory(FakeDAO, "nice_conn")), + }, + """ + injectable1 = Inject(Factory(FakeDAO)) + obj = await injectable1() + assert await obj.get_all() == ["awesome_data"] + assert obj.model == "Awesome" + assert obj.conn == "awesome_conn" + + injectable2 = Inject(Factory(FakeDAO, "nice_conn")) + obj = await injectable2() + assert await obj.get_all() == ["awesome_data"] + assert obj.model == "Awesome" + assert obj.conn == "nice_conn" + + assert injectable1 != injectable2 From 3f4cf47c40ddbe26143ca9b2e9eedef5651287df Mon Sep 17 00:00:00 2001 From: Shako Rzayev Date: Thu, 21 Sep 2023 00:01:56 +0200 Subject: [PATCH 3/7] fix: pass the linter --- esmerald/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esmerald/__init__.py b/esmerald/__init__.py index 0f9c1aee..1fcfd5be 100644 --- a/esmerald/__init__.py +++ b/esmerald/__init__.py @@ -5,7 +5,7 @@ from esmerald.conf import settings from esmerald.conf.global_settings import EsmeraldAPISettings -from esmerald.injector import Inject, Factory +from esmerald.injector import Factory, Inject from .applications import ChildEsmerald, Esmerald from .backgound import BackgroundTask, BackgroundTasks From 38c1e063be1c353228f1b2224657a3994cec30f8 Mon Sep 17 00:00:00 2001 From: Shako Rzayev Date: Fri, 22 Sep 2023 21:02:43 +0200 Subject: [PATCH 4/7] test: add Injects tests with Factory --- tests/conftest.py | 22 +++++++++++++++++ tests/test_inject.py | 19 ++++----------- tests/test_injects.py | 56 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b6fc230c..297b95d9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,23 @@ import functools import pathlib +from typing import Any, List import pytest +from esmerald import AsyncDAOProtocol from esmerald.testclient import EsmeraldTestClient +class FakeDAO(AsyncDAOProtocol): + model = "Awesome" + + def __init__(self, conn: str = "awesome_conn"): + self.conn = conn + + async def get_all(self, **kwargs: Any) -> List[Any]: + return ["awesome_data"] + + @pytest.fixture def no_trio_support(anyio_backend_name): # pragma: no cover if anyio_backend_name == "trio": @@ -37,3 +49,13 @@ def test_app_client_factory(anyio_backend_name, anyio_backend_options): @pytest.fixture() def template_dir(tmp_path: pathlib.Path) -> pathlib.Path: return tmp_path + + +@pytest.fixture(scope="module") +def get_fake_dao(): + return FakeDAO + + +@pytest.fixture(scope="module") +def get_fake_dao_instance(): + return FakeDAO() diff --git a/tests/test_inject.py b/tests/test_inject.py index ad8cd8b5..38cc5b6e 100644 --- a/tests/test_inject.py +++ b/tests/test_inject.py @@ -1,23 +1,12 @@ from functools import partial -from typing import Any, List +from typing import Any import pytest -from esmerald import AsyncDAOProtocol from esmerald.injector import Factory, Inject from esmerald.typing import Void -class FakeDAO(AsyncDAOProtocol): - model = "Awesome" - - def __init__(self, conn: str = "awesome_conn"): - self.conn = conn - - async def get_all(self, **kwargs: Any) -> List[Any]: - return ["awesome_data"] - - class Test: __test__ = False @@ -149,7 +138,7 @@ async def test_Inject_for_callable(fn: Any, exp: Any) -> None: @pytest.mark.asyncio() -async def test_if_DAO_is_injectable() -> None: +async def test_if_DAO_is_injectable(get_fake_dao) -> None: """ Current: dependencies={ @@ -168,13 +157,13 @@ async def test_if_DAO_is_injectable() -> None: "fake_dao": Inject(Factory(FakeDAO, "nice_conn")), }, """ - injectable1 = Inject(Factory(FakeDAO)) + injectable1 = Inject(Factory(get_fake_dao)) obj = await injectable1() assert await obj.get_all() == ["awesome_data"] assert obj.model == "Awesome" assert obj.conn == "awesome_conn" - injectable2 = Inject(Factory(FakeDAO, "nice_conn")) + injectable2 = Inject(Factory(get_fake_dao, "nice_conn")) obj = await injectable2() assert await obj.get_all() == ["awesome_data"] assert obj.model == "Awesome" diff --git a/tests/test_injects.py b/tests/test_injects.py index 5da70046..256b1d17 100644 --- a/tests/test_injects.py +++ b/tests/test_injects.py @@ -1,11 +1,11 @@ -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import pytest from starlette.status import HTTP_200_OK, HTTP_500_INTERNAL_SERVER_ERROR from esmerald.applications import Esmerald from esmerald.exceptions import ImproperlyConfigured -from esmerald.injector import Inject +from esmerald.injector import Factory, Inject from esmerald.params import Injects from esmerald.routing.gateways import Gateway from esmerald.routing.handlers import get @@ -13,6 +13,7 @@ from esmerald.routing.views import APIView from esmerald.testclient import create_client from esmerald.utils.constants import IS_DEPENDENCY +from tests.conftest import FakeDAO def test_is_dependency_inserted_into_field_extra() -> None: @@ -127,6 +128,17 @@ def test(value: int = Injects()) -> Dict[str, int]: assert resp.json() == {"value": 13} +def test_no_default_dependency_Injected_with_Factory(get_fake_dao) -> None: + @get(dependencies={"fake_dao": Inject(Factory(get_fake_dao))}) + 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]: @@ -150,6 +162,21 @@ def test(self, value: int = Injects()) -> Dict[str, int]: assert resp.json() == {"value": 13} +def test_dependency_Injected_on_APIView_with_Factory(get_fake_dao) -> None: + class C(APIView): + path = "" + dependencies = {"fake_dao": Inject(Factory(get_fake_dao))} + + @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]: @@ -172,3 +199,28 @@ def skipped(value: int = Injects(skip_validation=True)) -> Dict[str, int]: skipped_resp = client.get("/skipped") assert skipped_resp.status_code == HTTP_200_OK assert skipped_resp.json() == {"value": "str"} + + +def test_dependency_skip_validation_with_Factory(get_fake_dao) -> 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(get_fake_dao))}, + ) 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 f9cbf59eb28a62acc6f9e6b00f0154fc512beee3 Mon Sep 17 00:00:00 2001 From: Shako Rzayev Date: Fri, 22 Sep 2023 21:32:41 +0200 Subject: [PATCH 5/7] docs: update dependencies section add real world example of dependency injection with Factory --- docs/dependencies.md | 29 ++++++++++++++++++++++++++- docs_src/dependencies/urls.py | 36 ++++++++++++++++++++++++++++++++++ docs_src/dependencies/views.py | 21 ++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 docs_src/dependencies/urls.py create mode 100644 docs_src/dependencies/views.py diff --git a/docs/dependencies.md b/docs/dependencies.md index db2cf15a..466f6bc0 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -39,7 +39,7 @@ complexity you desire. {!> ../docs_src/dependencies/more_complex.py !} ``` -#### What is happenening +#### What is happening The `number` is obtained from the `first_dependency` and passed to the `second_dependency` as a result and validates and checks if the value is bigger or equal than 5 and that result `is_valid` is than passed to the main handler @@ -48,3 +48,30 @@ and checks if the value is bigger or equal than 5 and that result `is_valid` is {! ../docs_src/_shared/exceptions.md !} The same is applied also to [exception handlers](./exception-handlers.md). + +## More Real World example + +Now let's imagine that we have some web application with one of the `views.py` as: + +```python hl_lines="16" +{!> ../docs_src/dependencies/views.py !} +``` + +As you can notice the `user_dao` is injected automatically using appropriate level of dependency injection. + +Let's 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 !} +``` + +Here we use lambdas to create a callable from DAO instance. +> Read more about DAOs here: [Protocols](./protocols.md) + +But we have cleaner version of this, we call it `Factory` method. + +The Factory is a clean wrapper around any callable(yes classes usually are callables as well). +> No need to explicitly instantiate the class, just pass the name of the class to the Factory. + +In conclusion, if your views/routes expects dependencies, +define them in upper level as described and they will appear automatically. \ No newline at end of file diff --git a/docs_src/dependencies/urls.py b/docs_src/dependencies/urls.py new file mode 100644 index 00000000..1939bb70 --- /dev/null +++ b/docs_src/dependencies/urls.py @@ -0,0 +1,36 @@ +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(lambda: UserDAO()), + "article_dao": Inject(lambda: ArticleDAO()), + "post_dao": Inject(lambda: PostDAO()), + }, + ) +] + + +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(UserDAO)), + "article_dao": Inject(Factory(ArticleDAO)), + "post_dao": Inject(Factory(PostDAO)), + }, + ) +] diff --git a/docs_src/dependencies/views.py b/docs_src/dependencies/views.py new file mode 100644 index 00000000..adcaccdf --- /dev/null +++ b/docs_src/dependencies/views.py @@ -0,0 +1,21 @@ +from typing import List + +from esmerald import get +from esmerald.openapi.datastructures import OpenAPIResponse + + +@get( + "/users", + tags=["User"], + description="List of all the users in the system", + summary="Lists all users", + responses={ + 200: OpenAPIResponse(model=[UserOut]), + 400: OpenAPIResponse(model=Error, description="Bad response"), + }, +) +async def users(user_dao: UserDAO) -> List[UserOut]: + """ + Lists all the users in the system. + """ + return await user_dao.get_all() From 803592d7a01f0b9e8c9b09722abe0310c604497b Mon Sep 17 00:00:00 2001 From: Shako Rzayev Date: Fri, 22 Sep 2023 21:59:24 +0200 Subject: [PATCH 6/7] docs: update DAO link --- docs/dependencies.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/dependencies.md b/docs/dependencies.md index 466f6bc0..344f0922 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -53,7 +53,7 @@ The same is applied also to [exception handlers](./exception-handlers.md). Now let's imagine that we have some web application with one of the `views.py` as: -```python hl_lines="16" +```python hl_lines="17" {!> ../docs_src/dependencies/views.py !} ``` @@ -61,12 +61,12 @@ As you can notice the `user_dao` is injected automatically using appropriate lev Let's see the `urls.py` and understand from where we got the `user_dao`: -```python hl_lines="14-16 32-34" +```python hl_lines="13-15 31-33" {!> ../docs_src/dependencies/urls.py !} ``` Here we use lambdas to create a callable from DAO instance. -> Read more about DAOs here: [Protocols](./protocols.md) +> Read more about [DAOs](./protocols.md) But we have cleaner version of this, we call it `Factory` method. From 5a989c4749844b50def673096dd4687777c63fdd Mon Sep 17 00:00:00 2001 From: Shako Rzayev Date: Fri, 29 Sep 2023 20:44:47 +0200 Subject: [PATCH 7/7] docs: requested changes --- docs/dependencies.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/dependencies.md b/docs/dependencies.md index 344f0922..d0556376 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -51,13 +51,13 @@ The same is applied also to [exception handlers](./exception-handlers.md). ## More Real World example -Now let's imagine that we have some web application with one of the `views.py` as: +Now let us imagine that we have a web application with one of the views. Something like this: ```python hl_lines="17" {!> ../docs_src/dependencies/views.py !} ``` -As you can notice the `user_dao` is injected automatically using 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`: @@ -66,12 +66,21 @@ Let's see the `urls.py` and understand from where we got the `user_dao`: ``` Here we use lambdas to create a callable from DAO instance. -> Read more about [DAOs](./protocols.md) -But we have cleaner version of this, we call it `Factory` method. +!!! note -The Factory is a clean wrapper around any callable(yes classes usually are callables as well). -> No need to explicitly instantiate the class, just pass the name of the class to the Factory. + You can see the Python lambdas as the equivalent of the anonymous functions in JavaScript. You can always [see more details](https://www.w3schools.com/python/python_lambda.asp) about it. -In conclusion, if your views/routes expects dependencies, -define them in upper level as described and they will appear automatically. \ No newline at end of file +!!! note + + Learn more about [DAOs](./protocols.md) + +We do have a cleaner version of this though, in the Esmerald world we call it Factory. + +The Factory is a clean wrapper around any callable (classes usually are callables as well, even without instantiating the object itself). + +!!! note + + No need to explicitly instantiate the class, just pass the name of the class to the Factory. + +In conclusion, if your views/routes expect dependencies, you can define them in the upper level as described and they will automatically appear \ No newline at end of file