Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: naive Factory implementation for getting rid of lambdas #163

Merged
merged 8 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-suite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
branches:
- "**"
pull_request:
branches: ["main"]
branches: ["main", "v2"]
schedule:
- cron: "0 0 * * *"

Expand Down
38 changes: 37 additions & 1 deletion docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,3 +48,39 @@ 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 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 the appropriate level of dependency injection..

Let's see the `urls.py` and understand from where we got the `user_dao`:

```python hl_lines="13-15 31-33"
{!> ../docs_src/dependencies/urls.py !}
```

Here we use lambdas to create a callable from DAO instance.
ShahriyarR marked this conversation as resolved.
Show resolved Hide resolved

!!! note

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.

!!! 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
36 changes: 36 additions & 0 deletions docs_src/dependencies/urls.py
Original file line number Diff line number Diff line change
@@ -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)),
},
)
]
21 changes: 21 additions & 0 deletions docs_src/dependencies/views.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion esmerald/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 Factory, Inject

from .applications import ChildEsmerald, Esmerald
from .backgound import BackgroundTask, BackgroundTasks
Expand Down Expand Up @@ -72,6 +72,7 @@
"HTTPException",
"Include",
"Inject",
"Factory",
"Injects",
"ImproperlyConfigured",
"JSON",
Expand Down
24 changes: 23 additions & 1 deletion esmerald/injector.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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, ...] = ()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be:

self.__args: Tuple[Any, ...] = args

?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be done directly, but personally I prefer to use the getter and setter way of dealing with internal objects:

        self.__args: Tuple[Any, ...] = ()
        self.set_args(*args)

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)
Expand Down
22 changes: 22 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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":
Expand Down Expand Up @@ -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()
86 changes: 79 additions & 7 deletions tests/test_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from esmerald.injector import Inject
from esmerald.injector import Factory, Inject
from esmerald.typing import Void


Expand Down Expand Up @@ -49,20 +49,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
Expand All @@ -80,6 +98,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",
[
Expand All @@ -93,8 +122,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(get_fake_dao) -> 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(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(get_fake_dao, "nice_conn"))
obj = await injectable2()
assert await obj.get_all() == ["awesome_data"]
assert obj.model == "Awesome"
assert obj.conn == "nice_conn"

assert injectable1 != injectable2
Loading