Skip to content
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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,5 @@ out
.local

# temporary
rodi
shared-assets
copy-shared.sh
3 changes: 3 additions & 0 deletions blacksheep/docs/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ This page describes:
- [X] Examples of dependency injection.
- [X] How to use alternatives to `rodi`.

!!! info "Rodi's documentation"
Detailed documentation for Rodi can be found at: [_Rodi_](/rodi/).

## Introduction

The `Application` object exposes a `services` property that can be used to
Expand Down
Binary file added home/docs/img/rodi.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions home/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ the documentation of some of the projects.
image: ./img/blacksheep.png
url: /blacksheep/

- title: Rodi
content: |
Non-intrusive Dependency Injection for Python.
image: ./img/rodi.png
url: /rodi/

- title: MkDocs-Plugins
content: |
Plugins for Python Markdown designed for MkDocs and Material for MkDocs.
Expand Down
1 change: 1 addition & 0 deletions home/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ edit_uri: ""
nav:
- Index: index.md
- BlackSheep: /blacksheep/
- Rodi: /rodi/
- MkDocs-Plugins: /mkdocs-plugins/

theme:
Expand Down
1 change: 1 addition & 0 deletions pack.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#! /bin/bash
folders=(
blacksheep
rodi
mkdocs-plugins
)

Expand Down
3 changes: 3 additions & 0 deletions rodi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Rodi docs 📜

[www.neoteroi.dev](https://www.neoteroi.dev/rodi/).
17 changes: 17 additions & 0 deletions rodi/docs/about.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# About Rodi

Rodi born from the desire of using a `non-intrusive` implementation of
Dependency Injection for Python, that does not require modifying the code of
types it resolved using decorators, like most others existing implementations
of DI for Python. Type annotations make explicit decorators superfluous as
the DI container can inspect the code to obtain all information it needs to
resolve types.

Rodi is the built-in DI framework in the [BlackSheep](/blacksheep/) web
framework, although it can be replaced with alternative solutions if desired.

## The project's home

The project is hosted in [GitHub](https://github.com/Neoteroi/rodi),
handled following DevOps good practices, and is published to
[pypi.org](https://pypi.org/project/rodi/).
197 changes: 197 additions & 0 deletions rodi/docs/async.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
As explained in [_Getting Started_](./getting-started.md), Rodi's objective is to
simplify constructing objects based on constructors and class properties.
Support for async resolution is intentionally out of the scope of the library because
constructing objects should be lightweight.

This page provides guidelines for working with objects that require asynchronous
initialization.

## A common example

A common example of this situation are objects that handle TCP/IP connection pooling,
such as `HTTP` clients and database clients. These objects are usually implemented as
*context managers* in Python because they need to implement connection pooling and
gracefully close TCP connections when disposed.

Python supports [`asynchronous` context managers](https://peps.python.org/pep-0492/#asynchronous-context-managers-and-async-with) for this kind of scenario.

Consider the following example, of a `SendGrid` API client to send emails using the
SendGrid API, with asynchronous code and using [`httpx`](https://www.python-httpx.org/async/).

```python {linenums="1"}
# domain/emails.py
from abc import ABC, abstractmethod
from dataclasses import dataclass


# TODO: use Pydantic for the Email object.
@dataclass
class Email:
recipients: list[str]
sender: str
sender_name: str
subject: str
body: str
cc: list[str] = None
bcc: list[str] = None


class EmailHandler(ABC): # interface
@abstractmethod
async def send(self, email: Email) -> None:
pass
```

```python {linenums="1", hl_lines="24 32"}
# data/apis/sendgrid.py
import os
from dataclasses import dataclass

import httpx

from domain.emails import Email, EmailHandler


@dataclass
class SendGridClientSettings:
api_key: str

@classmethod
def from_env(cls):
api_key = os.environ.get("SENDGRID_API_KEY")
if not api_key:
raise ValueError("SENDGRID_API_KEY environment variable is required")
return cls(api_key=api_key)


class SendGridClient(EmailHandler):
def __init__(
self, settings: SendGridClientSettings, http_client: httpx.AsyncClient
):
if not settings.api_key:
raise ValueError("API key is required")
self.http_client = http_client
self.api_key = settings.api_key

async def send(self, email: Email) -> None:
response = await self.http_client.post(
"https://api.sendgrid.com/v3/mail/send",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=self.get_body(email),
)
# Note: in case of error, inspect response.text
response.raise_for_status() # Raise an error for bad responses

def get_body(self, email: Email) -> dict:
return {
"personalizations": [
{
"to": [{"email": recipient} for recipient in email.recipients],
"subject": email.subject,
"cc": [{"email": cc} for cc in email.cc] if email.cc else None,
"bcc": [{"email": bcc} for bcc in email.bcc] if email.bcc else None,
}
],
"from": {"email": email.sender, "name": email.sender_name},
"content": [{"type": "text/html", "value": email.body}],
}
```

/// details | The official SendGrid Python SDK does not support async.
type: danger

At the time of this writing, the official SendGrid Python SDK does not support `async`.
Its documentation provides a wrong example for `async` code (see [_issue #988_](https://github.com/sendgrid/sendgrid-python/issues/988)).
The SendGrid REST API is very well documented and comfortable to use! Use a class like
the one shown on this page to send emails using SendGrid in async code.
///

The **SendGridClient** depends on an instance of `SendGridClientSettings` (providing a
SendGrid API Key), and on an instance of `httpx.AsyncClient` able to make HTTP requests.

The code below shows how to register the object that requires asynchronous
initialization and use it across the lifetime of your application.

```python {linenums="1", hl_lines="12-20 25 40-41 44-46 48"}
# main.py
import asyncio
from contextlib import asynccontextmanager

import httpx
from rodi import Container

from data.apis.sendgrid import SendGridClient, SendGridClientSettings
from domain.emails import EmailHandler


@asynccontextmanager
async def register_http_client(container: Container):

async with httpx.AsyncClient() as http_client:
print("HTTP client initialized")
container.add_instance(http_client)
yield

print("HTTP client disposed")


async def application_runtime(container: Container):
# Entry point for what your application does
email_handler = container.resolve(EmailHandler)
assert isinstance(email_handler, SendGridClient)
assert isinstance(email_handler.http_client, httpx.AsyncClient)

# We can use the HTTP Client during the lifetime of the Application
print("All is good! ✨")


def sendgrid_settings_factory() -> SendGridClientSettings:
return SendGridClientSettings.from_env()


async def main():
# Bootstrap code for the application
container = Container()
container.add_singleton_by_factory(sendgrid_settings_factory)
container.add_singleton(EmailHandler, SendGridClient)

async with register_http_client(container) as http_client:
container.add_instance(
http_client
) # <-- Configure the HTTP client as singleton

await application_runtime(container)


if __name__ == "__main__":
asyncio.run(main())
```

The above code displays the following:

```bash
$ SENDGRID_API_KEY="***" python main.py

HTTP client initialized
All is good! ✨
HTTP client disposed
```

## Considerations

- It is not Rodi's responsibility to administer the lifecycle of the application. It is
the responsibility of the code that bootstrap the application, to handle objects that
require asynchronous initialization and disposal.
- Python's `asynccontextmanager` is convenient for these scenarios.
- In the example above, the HTTP Client is configured as singleton to benefit from TCP
connection pooling. It would also be possible to configure it as transient or scoped
service, as long as all instances share the same connection pool. In the case of
`httpx`, you can read on this subject here: [Why use a Client?](https://www.python-httpx.org/advanced/clients/#why-use-a-client).
- Dependency Injection likes custom classes to describe _settings_ for types,
because registering simple types (`str`, `int`, `float`, etc.) in the container does
not scale and should be avoided.

The next page explains how Rodi handles [context managers](./context-managers.md).
113 changes: 113 additions & 0 deletions rodi/docs/context-managers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
This page describes how to work with Rodi and context managers.

## How Rodi handles context managers

When a class implements the context manager protocol (`__enter__`, `__exit__`),
Rodi instantiates the class but does **not** enter nor exit the instance
automatically.

```python {linenums="1", hl_lines="4 10 15 25-26 41"}
from rodi import Container


class A:
def __init__(self) -> None:
print("A created")
self.initialized = False
self.disposed = False

def __enter__(self) -> "A":
print("A initialized")
self.initialized = True
return self

def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.disposed = True
print("A destroyed")


class B:
def __init__(self, dependency: A) -> None:
self.dependency = dependency

def do_work(self):
with self.dependency:
print("Do work")


container = Container()

container.register(A)
container.register(B)

b = container.resolve(B)

# b.dependency is instantiated and provided as is, it is not entered
# automatically
assert b.dependency.initialized is False
assert b.dependency.disposed is False

b.do_work()
assert b.dependency.initialized is True
assert b.dependency.disposed is True
```

/// admonition | Rodi does not enter and exit contexts.
type: into

There is no way to unambiguously know the intentions of the developer:
should a context be entered automatically and disposed automatically?
///

## Async context managers

As described above for context managers, Rodi does not handle async context
managers in any special way either.

```python {linenums="1", hl_lines="6 12 17 26-27 41"}
import asyncio

from rodi import Container


class A:
def __init__(self) -> None:
print("A created")
self.initialized = False
self.disposed = False

async def __aenter__(self) -> "A":
print("A initialized")
self.initialized = True
return self

async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
self.disposed = True
print("A destroyed")


class B:
def __init__(self, dependency: A) -> None:
self.dependency = dependency

async def do_work(self):
async with self.dependency:
print("Do work")


container = Container()

container.register(A)
container.register(B)

b = container.resolve(B)
assert b.dependency.initialized is False
assert b.dependency.disposed is False


asyncio.run(b.do_work())
assert b.dependency.initialized is True
assert b.dependency.disposed is True
```

The next page describes support for [_Union types_](./union-types.md) in Rodi.
Loading