|
| 1 | +As explained in [_Getting Started_](./getting-started.md), Rodi's objective is to |
| 2 | +simplify constructing objects based on constructors and class properties. |
| 3 | +Support for async resolution is intentionally out of the scope of the library because |
| 4 | +constructing objects should be lightweight. |
| 5 | + |
| 6 | +This page provides guidelines for working with objects that require asynchronous |
| 7 | +initialization. |
| 8 | + |
| 9 | +## A common example |
| 10 | + |
| 11 | +A common example of this situation are objects that handle TCP/IP connection pooling, |
| 12 | +such as `HTTP` clients and database clients. These objects are usually implemented as |
| 13 | +*context managers* in Python because they need to implement connection pooling and |
| 14 | +gracefully close TCP connections when disposed. |
| 15 | + |
| 16 | +Python supports [`asynchronous` context managers](https://peps.python.org/pep-0492/#asynchronous-context-managers-and-async-with) for this kind of scenario. |
| 17 | + |
| 18 | +Consider the following example, of a `SendGrid` API client to send emails using the |
| 19 | +SendGrid API, with asynchronous code and using [`httpx`](https://www.python-httpx.org/async/). |
| 20 | + |
| 21 | +```python {linenums="1"} |
| 22 | +# domain/emails.py |
| 23 | +from abc import ABC, abstractmethod |
| 24 | +from dataclasses import dataclass |
| 25 | + |
| 26 | + |
| 27 | +# TODO: use Pydantic for the Email object. |
| 28 | +@dataclass |
| 29 | +class Email: |
| 30 | + recipients: list[str] |
| 31 | + sender: str |
| 32 | + sender_name: str |
| 33 | + subject: str |
| 34 | + body: str |
| 35 | + cc: list[str] = None |
| 36 | + bcc: list[str] = None |
| 37 | + |
| 38 | + |
| 39 | +class EmailHandler(ABC): # interface |
| 40 | + @abstractmethod |
| 41 | + async def send(self, email: Email) -> None: |
| 42 | + pass |
| 43 | +``` |
| 44 | + |
| 45 | +```python {linenums="1", hl_lines="24 32"} |
| 46 | +# data/apis/sendgrid.py |
| 47 | +import os |
| 48 | +from dataclasses import dataclass |
| 49 | + |
| 50 | +import httpx |
| 51 | + |
| 52 | +from domain.emails import Email, EmailHandler |
| 53 | + |
| 54 | + |
| 55 | +@dataclass |
| 56 | +class SendGridClientSettings: |
| 57 | + api_key: str |
| 58 | + |
| 59 | + @classmethod |
| 60 | + def from_env(cls): |
| 61 | + api_key = os.environ.get("SENDGRID_API_KEY") |
| 62 | + if not api_key: |
| 63 | + raise ValueError("SENDGRID_API_KEY environment variable is required") |
| 64 | + return cls(api_key=api_key) |
| 65 | + |
| 66 | + |
| 67 | +class SendGridClient(EmailHandler): |
| 68 | + def __init__( |
| 69 | + self, settings: SendGridClientSettings, http_client: httpx.AsyncClient |
| 70 | + ): |
| 71 | + if not settings.api_key: |
| 72 | + raise ValueError("API key is required") |
| 73 | + self.http_client = http_client |
| 74 | + self.api_key = settings.api_key |
| 75 | + |
| 76 | + async def send(self, email: Email) -> None: |
| 77 | + response = await self.http_client.post( |
| 78 | + "https://api.sendgrid.com/v3/mail/send", |
| 79 | + headers={ |
| 80 | + "Authorization": f"Bearer {self.api_key}", |
| 81 | + "Content-Type": "application/json", |
| 82 | + }, |
| 83 | + json=self.get_body(email), |
| 84 | + ) |
| 85 | + # Note: in case of error, inspect response.text |
| 86 | + response.raise_for_status() # Raise an error for bad responses |
| 87 | + |
| 88 | + def get_body(self, email: Email) -> dict: |
| 89 | + return { |
| 90 | + "personalizations": [ |
| 91 | + { |
| 92 | + "to": [{"email": recipient} for recipient in email.recipients], |
| 93 | + "subject": email.subject, |
| 94 | + "cc": [{"email": cc} for cc in email.cc] if email.cc else None, |
| 95 | + "bcc": [{"email": bcc} for bcc in email.bcc] if email.bcc else None, |
| 96 | + } |
| 97 | + ], |
| 98 | + "from": {"email": email.sender, "name": email.sender_name}, |
| 99 | + "content": [{"type": "text/html", "value": email.body}], |
| 100 | + } |
| 101 | +``` |
| 102 | + |
| 103 | +/// details | The official SendGrid Python SDK does not support async. |
| 104 | + type: danger |
| 105 | + |
| 106 | +At the time of this writing, the official SendGrid Python SDK does not support `async`. |
| 107 | +Its documentation provides a wrong example for `async` code (see [_issue #988_](https://github.com/sendgrid/sendgrid-python/issues/988)). |
| 108 | +The SendGrid REST API is very well documented and comfortable to use! Use a class like |
| 109 | +the one shown on this page to send emails using SendGrid in async code. |
| 110 | +/// |
| 111 | + |
| 112 | +The **SendGridClient** depends on an instance of `SendGridClientSettings` (providing a |
| 113 | +SendGrid API Key), and on an instance of `httpx.AsyncClient` able to make HTTP requests. |
| 114 | + |
| 115 | +The code below shows how to register the object that requires asynchronous |
| 116 | +initialization and use it across the lifetime of your application. |
| 117 | + |
| 118 | +```python {linenums="1", hl_lines="12-20 25 40-41 44-46 48"} |
| 119 | +# main.py |
| 120 | +import asyncio |
| 121 | +from contextlib import asynccontextmanager |
| 122 | + |
| 123 | +import httpx |
| 124 | +from rodi import Container |
| 125 | + |
| 126 | +from data.apis.sendgrid import SendGridClient, SendGridClientSettings |
| 127 | +from domain.emails import EmailHandler |
| 128 | + |
| 129 | + |
| 130 | +@asynccontextmanager |
| 131 | +async def register_http_client(container: Container): |
| 132 | + |
| 133 | + async with httpx.AsyncClient() as http_client: |
| 134 | + print("HTTP client initialized") |
| 135 | + container.add_instance(http_client) |
| 136 | + yield |
| 137 | + |
| 138 | + print("HTTP client disposed") |
| 139 | + |
| 140 | + |
| 141 | +async def application_runtime(container: Container): |
| 142 | + # Entry point for what your application does |
| 143 | + email_handler = container.resolve(EmailHandler) |
| 144 | + assert isinstance(email_handler, SendGridClient) |
| 145 | + assert isinstance(email_handler.http_client, httpx.AsyncClient) |
| 146 | + |
| 147 | + # We can use the HTTP Client during the lifetime of the Application |
| 148 | + print("All is good! ✨") |
| 149 | + |
| 150 | + |
| 151 | +def sendgrid_settings_factory() -> SendGridClientSettings: |
| 152 | + return SendGridClientSettings.from_env() |
| 153 | + |
| 154 | + |
| 155 | +async def main(): |
| 156 | + # Bootstrap code for the application |
| 157 | + container = Container() |
| 158 | + container.add_singleton_by_factory(sendgrid_settings_factory) |
| 159 | + container.add_singleton(EmailHandler, SendGridClient) |
| 160 | + |
| 161 | + async with register_http_client(container) as http_client: |
| 162 | + container.add_instance( |
| 163 | + http_client |
| 164 | + ) # <-- Configure the HTTP client as singleton |
| 165 | + |
| 166 | + await application_runtime(container) |
| 167 | + |
| 168 | + |
| 169 | +if __name__ == "__main__": |
| 170 | + asyncio.run(main()) |
| 171 | +``` |
| 172 | + |
| 173 | +The above code displays the following: |
| 174 | + |
| 175 | +```bash |
| 176 | +$ SENDGRID_API_KEY="***" python main.py |
| 177 | + |
| 178 | +HTTP client initialized |
| 179 | +All is good! ✨ |
| 180 | +HTTP client disposed |
| 181 | +``` |
| 182 | + |
| 183 | +## Considerations |
| 184 | + |
| 185 | +- It is not Rodi's responsibility to administer the lifecycle of the application. It is |
| 186 | + the responsibility of the code that bootstrap the application, to handle objects that |
| 187 | + require asynchronous initialization and disposal. |
| 188 | +- Python's `asynccontextmanager` is convenient for these scenarios. |
| 189 | +- In the example above, the HTTP Client is configured as singleton to benefit from TCP |
| 190 | + connection pooling. It would also be possible to configure it as transient or scoped |
| 191 | + service, as long as all instances share the same connection pool. In the case of |
| 192 | + `httpx`, you can read on this subject here: [Why use a Client?](https://www.python-httpx.org/advanced/clients/#why-use-a-client). |
| 193 | +- Dependency Injection likes custom classes to describe _settings_ for types, |
| 194 | + because registering simple types (`str`, `int`, `float`, etc.) in the container does |
| 195 | + not scale and should be avoided. |
| 196 | + |
| 197 | +The next page explains how Rodi handles [context managers](./context-managers.md). |
0 commit comments