diff --git a/.gitignore b/.gitignore index 1356e3a..8669775 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,5 @@ out .local # temporary -rodi shared-assets copy-shared.sh diff --git a/blacksheep/docs/dependency-injection.md b/blacksheep/docs/dependency-injection.md index 4a2dd84..bfadd09 100644 --- a/blacksheep/docs/dependency-injection.md +++ b/blacksheep/docs/dependency-injection.md @@ -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 diff --git a/home/docs/img/rodi.png b/home/docs/img/rodi.png new file mode 100644 index 0000000..e4b16af Binary files /dev/null and b/home/docs/img/rodi.png differ diff --git a/home/docs/index.md b/home/docs/index.md index 8e247c6..303afc8 100644 --- a/home/docs/index.md +++ b/home/docs/index.md @@ -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. diff --git a/home/mkdocs.yml b/home/mkdocs.yml index 59e178d..d6e27fd 100644 --- a/home/mkdocs.yml +++ b/home/mkdocs.yml @@ -9,6 +9,7 @@ edit_uri: "" nav: - Index: index.md - BlackSheep: /blacksheep/ + - Rodi: /rodi/ - MkDocs-Plugins: /mkdocs-plugins/ theme: diff --git a/pack.sh b/pack.sh index 32d4337..c9b6512 100755 --- a/pack.sh +++ b/pack.sh @@ -1,6 +1,7 @@ #! /bin/bash folders=( blacksheep + rodi mkdocs-plugins ) diff --git a/rodi/README.md b/rodi/README.md new file mode 100644 index 0000000..1ca1b43 --- /dev/null +++ b/rodi/README.md @@ -0,0 +1,3 @@ +# Rodi docs 📜 + +[www.neoteroi.dev](https://www.neoteroi.dev/rodi/). diff --git a/rodi/docs/about.md b/rodi/docs/about.md new file mode 100644 index 0000000..bb19eb7 --- /dev/null +++ b/rodi/docs/about.md @@ -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/). diff --git a/rodi/docs/async.md b/rodi/docs/async.md new file mode 100644 index 0000000..f124d78 --- /dev/null +++ b/rodi/docs/async.md @@ -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). diff --git a/rodi/docs/context-managers.md b/rodi/docs/context-managers.md new file mode 100644 index 0000000..e66458b --- /dev/null +++ b/rodi/docs/context-managers.md @@ -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. diff --git a/rodi/docs/css/extra.css b/rodi/docs/css/extra.css new file mode 100644 index 0000000..d7efc58 --- /dev/null +++ b/rodi/docs/css/extra.css @@ -0,0 +1,127 @@ +[data-md-color-scheme=slate] { + --md-code-hl-comment-color: #33b227 !important; /* #b28027 */ +} + +[data-md-color-scheme=default] { + --md-code-hl-comment-color: #b91414; /* #ab0404; */ +} + +html { + overflow-y: scroll; +} + +.md-typeset__table tr td code { + white-space: nowrap; +} + +@media screen and (min-width: 1000px) { + html.fullscreen { + .md-grid { + max-width: 98%; + } + + .md-sidebar { + width: auto; + min-width: 15%; + } + } +} + +#fullscreen-form label { + display: none; +} + +html:not(.fullscreen) #full-screen { + display: inline-block !important; +} + +html.fullscreen #full-screen-exit { + display: inline-block !important; +} + +html.fullscreen #full-screen { + display: none !important; +} + +[data-md-color-scheme="default"] .md-typeset [type="checkbox"]:checked + .task-list-indicator::before { + background-color: #2d319f; +} + +[data-md-color-scheme="slate"] .md-typeset [type="checkbox"]:checked + .task-list-indicator::before { + background-color: #00e6e6; +} + +[data-md-color-scheme="slate"] { + --md-accent-fg-color: #21bbd0; + --md-primary-fg-color: #53ebff; + --md-primary-fg-color--light: #5db0c0; + --md-primary-fg-color--dark: #308ea1; +} + +.md-search__input, .md-header .md-search__input::placeholder, .md-search__input + .md-search__icon { + color: #000 !important; +} + +.md-header { + background-color: var(--bg-color, teal); +} + +.md-content article { + margin-bottom: 3em; +} + +.md-header .md-search__input { + background-color: #fff; +} + +.md-header-nav__button.md-logo img, .md-header-nav__button.md-logo svg { + width: 1.8rem; + height: auto; +} + +.img-full-width + p img { + width: 100%; +} + +.img-auto-width + p img { + width: none; +} + +.md-typeset h1 { + margin: 0 0 1em; +} + +.small { + font-size: 14px; +} + +span.task-list-indicator { + margin-right: 5px; +} + +@media screen and (max-width: 76.1875em) { + .md-nav--primary .md-nav__title[for=__drawer] { + background-color: #000000; + } +} + +:root { + --nt-color-7: #108d10; +} + +.version-warning { + margin-top: 5px !important; +} + +.md-typeset .tabbed-labels>label, .md-typeset .admonition, .md-typeset details { + font-size: .75rem !important; +} +/* +.md-typeset code { + font-size: .9em !important; +} + +table td code { + white-space: nowrap; +} +*/ diff --git a/rodi/docs/css/neoteroi.css b/rodi/docs/css/neoteroi.css new file mode 100644 index 0000000..cd8c6cf --- /dev/null +++ b/rodi/docs/css/neoteroi.css @@ -0,0 +1 @@ +:root{--nt-color-0: #CD853F;--nt-color-1: #B22222;--nt-color-2: #000080;--nt-color-3: #4B0082;--nt-color-4: #3CB371;--nt-color-5: #D2B48C;--nt-color-6: #FF00FF;--nt-color-7: #98FB98;--nt-color-8: #FFEBCD;--nt-color-9: #2E8B57;--nt-color-10: #6A5ACD;--nt-color-11: #48D1CC;--nt-color-12: #FFA500;--nt-color-13: #F4A460;--nt-color-14: #A52A2A;--nt-color-15: #FFE4C4;--nt-color-16: #FF4500;--nt-color-17: #AFEEEE;--nt-color-18: #FA8072;--nt-color-19: #2F4F4F;--nt-color-20: #FFDAB9;--nt-color-21: #BC8F8F;--nt-color-22: #FFC0CB;--nt-color-23: #00FA9A;--nt-color-24: #F0FFF0;--nt-color-25: #FFFACD;--nt-color-26: #F5F5F5;--nt-color-27: #FF6347;--nt-color-28: #FFFFF0;--nt-color-29: #7FFFD4;--nt-color-30: #E9967A;--nt-color-31: #7B68EE;--nt-color-32: #FFF8DC;--nt-color-33: #0000CD;--nt-color-34: #D2691E;--nt-color-35: #708090;--nt-color-36: #5F9EA0;--nt-color-37: #008080;--nt-color-38: #008000;--nt-color-39: #FFE4E1;--nt-color-40: #FFFF00;--nt-color-41: #FFFAF0;--nt-color-42: #DCDCDC;--nt-color-43: #ADFF2F;--nt-color-44: #ADD8E6;--nt-color-45: #8B008B;--nt-color-46: #7FFF00;--nt-color-47: #800000;--nt-color-48: #20B2AA;--nt-color-49: #556B2F;--nt-color-50: #778899;--nt-color-51: #E6E6FA;--nt-color-52: #FFFAFA;--nt-color-53: #FF7F50;--nt-color-54: #FF0000;--nt-color-55: #F5DEB3;--nt-color-56: #008B8B;--nt-color-57: #66CDAA;--nt-color-58: #808000;--nt-color-59: #FAF0E6;--nt-color-60: #00BFFF;--nt-color-61: #C71585;--nt-color-62: #00FFFF;--nt-color-63: #8B4513;--nt-color-64: #F0F8FF;--nt-color-65: #FAEBD7;--nt-color-66: #8B0000;--nt-color-67: #4682B4;--nt-color-68: #F0E68C;--nt-color-69: #BDB76B;--nt-color-70: #A0522D;--nt-color-71: #FAFAD2;--nt-color-72: #FFD700;--nt-color-73: #DEB887;--nt-color-74: #E0FFFF;--nt-color-75: #8A2BE2;--nt-color-76: #32CD32;--nt-color-77: #87CEFA;--nt-color-78: #00CED1;--nt-color-79: #696969;--nt-color-80: #DDA0DD;--nt-color-81: #EE82EE;--nt-color-82: #FFB6C1;--nt-color-83: #8FBC8F;--nt-color-84: #D8BFD8;--nt-color-85: #9400D3;--nt-color-86: #A9A9A9;--nt-color-87: #FFFFE0;--nt-color-88: #FFF5EE;--nt-color-89: #FFF0F5;--nt-color-90: #FFDEAD;--nt-color-91: #800080;--nt-color-92: #B0E0E6;--nt-color-93: #9932CC;--nt-color-94: #DAA520;--nt-color-95: #F0FFFF;--nt-color-96: #40E0D0;--nt-color-97: #00FF7F;--nt-color-98: #006400;--nt-color-99: #808080;--nt-color-100: #87CEEB;--nt-color-101: #0000FF;--nt-color-102: #6495ED;--nt-color-103: #FDF5E6;--nt-color-104: #B8860B;--nt-color-105: #BA55D3;--nt-color-106: #C0C0C0;--nt-color-107: #000000;--nt-color-108: #F08080;--nt-color-109: #B0C4DE;--nt-color-110: #00008B;--nt-color-111: #6B8E23;--nt-color-112: #FFE4B5;--nt-color-113: #FFA07A;--nt-color-114: #9ACD32;--nt-color-115: #FFFFFF;--nt-color-116: #F5F5DC;--nt-color-117: #90EE90;--nt-color-118: #1E90FF;--nt-color-119: #7CFC00;--nt-color-120: #FF69B4;--nt-color-121: #F8F8FF;--nt-color-122: #F5FFFA;--nt-color-123: #00FF00;--nt-color-124: #D3D3D3;--nt-color-125: #DB7093;--nt-color-126: #DA70D6;--nt-color-127: #FF1493;--nt-color-128: #228B22;--nt-color-129: #FFEFD5;--nt-color-130: #4169E1;--nt-color-131: #191970;--nt-color-132: #9370DB;--nt-color-133: #483D8B;--nt-color-134: #FF8C00;--nt-color-135: #EEE8AA;--nt-color-136: #CD5C5C;--nt-color-137: #DC143C}:root{--nt-group-0-main: #000000;--nt-group-0-dark: #FFFFFF;--nt-group-0-light: #000000;--nt-group-0-main-bg: #F44336;--nt-group-0-dark-bg: #BA000D;--nt-group-0-light-bg: #FF7961;--nt-group-1-main: #000000;--nt-group-1-dark: #FFFFFF;--nt-group-1-light: #000000;--nt-group-1-main-bg: #E91E63;--nt-group-1-dark-bg: #B0003A;--nt-group-1-light-bg: #FF6090;--nt-group-2-main: #FFFFFF;--nt-group-2-dark: #FFFFFF;--nt-group-2-light: #000000;--nt-group-2-main-bg: #9C27B0;--nt-group-2-dark-bg: #6A0080;--nt-group-2-light-bg: #D05CE3;--nt-group-3-main: #FFFFFF;--nt-group-3-dark: #FFFFFF;--nt-group-3-light: #000000;--nt-group-3-main-bg: #673AB7;--nt-group-3-dark-bg: #320B86;--nt-group-3-light-bg: #9A67EA;--nt-group-4-main: #FFFFFF;--nt-group-4-dark: #FFFFFF;--nt-group-4-light: #000000;--nt-group-4-main-bg: #3F51B5;--nt-group-4-dark-bg: #002984;--nt-group-4-light-bg: #757DE8;--nt-group-5-main: #000000;--nt-group-5-dark: #FFFFFF;--nt-group-5-light: #000000;--nt-group-5-main-bg: #2196F3;--nt-group-5-dark-bg: #0069C0;--nt-group-5-light-bg: #6EC6FF;--nt-group-6-main: #000000;--nt-group-6-dark: #FFFFFF;--nt-group-6-light: #000000;--nt-group-6-main-bg: #03A9F4;--nt-group-6-dark-bg: #007AC1;--nt-group-6-light-bg: #67DAFF;--nt-group-7-main: #000000;--nt-group-7-dark: #000000;--nt-group-7-light: #000000;--nt-group-7-main-bg: #00BCD4;--nt-group-7-dark-bg: #008BA3;--nt-group-7-light-bg: #62EFFF;--nt-group-8-main: #000000;--nt-group-8-dark: #FFFFFF;--nt-group-8-light: #000000;--nt-group-8-main-bg: #009688;--nt-group-8-dark-bg: #00675B;--nt-group-8-light-bg: #52C7B8;--nt-group-9-main: #000000;--nt-group-9-dark: #FFFFFF;--nt-group-9-light: #000000;--nt-group-9-main-bg: #4CAF50;--nt-group-9-dark-bg: #087F23;--nt-group-9-light-bg: #80E27E;--nt-group-10-main: #000000;--nt-group-10-dark: #000000;--nt-group-10-light: #000000;--nt-group-10-main-bg: #8BC34A;--nt-group-10-dark-bg: #5A9216;--nt-group-10-light-bg: #BEF67A;--nt-group-11-main: #000000;--nt-group-11-dark: #000000;--nt-group-11-light: #000000;--nt-group-11-main-bg: #CDDC39;--nt-group-11-dark-bg: #99AA00;--nt-group-11-light-bg: #FFFF6E;--nt-group-12-main: #000000;--nt-group-12-dark: #000000;--nt-group-12-light: #000000;--nt-group-12-main-bg: #FFEB3B;--nt-group-12-dark-bg: #C8B900;--nt-group-12-light-bg: #FFFF72;--nt-group-13-main: #000000;--nt-group-13-dark: #000000;--nt-group-13-light: #000000;--nt-group-13-main-bg: #FFC107;--nt-group-13-dark-bg: #C79100;--nt-group-13-light-bg: #FFF350;--nt-group-14-main: #000000;--nt-group-14-dark: #000000;--nt-group-14-light: #000000;--nt-group-14-main-bg: #FF9800;--nt-group-14-dark-bg: #C66900;--nt-group-14-light-bg: #FFC947;--nt-group-15-main: #000000;--nt-group-15-dark: #FFFFFF;--nt-group-15-light: #000000;--nt-group-15-main-bg: #FF5722;--nt-group-15-dark-bg: #C41C00;--nt-group-15-light-bg: #FF8A50;--nt-group-16-main: #FFFFFF;--nt-group-16-dark: #FFFFFF;--nt-group-16-light: #000000;--nt-group-16-main-bg: #795548;--nt-group-16-dark-bg: #4B2C20;--nt-group-16-light-bg: #A98274;--nt-group-17-main: #000000;--nt-group-17-dark: #FFFFFF;--nt-group-17-light: #000000;--nt-group-17-main-bg: #9E9E9E;--nt-group-17-dark-bg: #707070;--nt-group-17-light-bg: #CFCFCF;--nt-group-18-main: #000000;--nt-group-18-dark: #FFFFFF;--nt-group-18-light: #000000;--nt-group-18-main-bg: #607D8B;--nt-group-18-dark-bg: #34515E;--nt-group-18-light-bg: #8EACBB}.nt-pastello{--nt-group-0-main: #000000;--nt-group-0-dark: #000000;--nt-group-0-light: #000000;--nt-group-0-main-bg: #EF9A9A;--nt-group-0-dark-bg: #BA6B6C;--nt-group-0-light-bg: #FFCCCB;--nt-group-1-main: #000000;--nt-group-1-dark: #000000;--nt-group-1-light: #000000;--nt-group-1-main-bg: #F48FB1;--nt-group-1-dark-bg: #BF5F82;--nt-group-1-light-bg: #FFC1E3;--nt-group-2-main: #000000;--nt-group-2-dark: #000000;--nt-group-2-light: #000000;--nt-group-2-main-bg: #CE93D8;--nt-group-2-dark-bg: #9C64A6;--nt-group-2-light-bg: #FFC4FF;--nt-group-3-main: #000000;--nt-group-3-dark: #000000;--nt-group-3-light: #000000;--nt-group-3-main-bg: #B39DDB;--nt-group-3-dark-bg: #836FA9;--nt-group-3-light-bg: #E6CEFF;--nt-group-4-main: #000000;--nt-group-4-dark: #000000;--nt-group-4-light: #000000;--nt-group-4-main-bg: #9FA8DA;--nt-group-4-dark-bg: #6F79A8;--nt-group-4-light-bg: #D1D9FF;--nt-group-5-main: #000000;--nt-group-5-dark: #000000;--nt-group-5-light: #000000;--nt-group-5-main-bg: #90CAF9;--nt-group-5-dark-bg: #5D99C6;--nt-group-5-light-bg: #C3FDFF;--nt-group-6-main: #000000;--nt-group-6-dark: #000000;--nt-group-6-light: #000000;--nt-group-6-main-bg: #81D4FA;--nt-group-6-dark-bg: #4BA3C7;--nt-group-6-light-bg: #B6FFFF;--nt-group-7-main: #000000;--nt-group-7-dark: #000000;--nt-group-7-light: #000000;--nt-group-7-main-bg: #80DEEA;--nt-group-7-dark-bg: #4BACB8;--nt-group-7-light-bg: #B4FFFF;--nt-group-8-main: #000000;--nt-group-8-dark: #000000;--nt-group-8-light: #000000;--nt-group-8-main-bg: #80CBC4;--nt-group-8-dark-bg: #4F9A94;--nt-group-8-light-bg: #B2FEF7;--nt-group-9-main: #000000;--nt-group-9-dark: #000000;--nt-group-9-light: #000000;--nt-group-9-main-bg: #A5D6A7;--nt-group-9-dark-bg: #75A478;--nt-group-9-light-bg: #D7FFD9;--nt-group-10-main: #000000;--nt-group-10-dark: #000000;--nt-group-10-light: #000000;--nt-group-10-main-bg: #C5E1A5;--nt-group-10-dark-bg: #94AF76;--nt-group-10-light-bg: #F8FFD7;--nt-group-11-main: #000000;--nt-group-11-dark: #000000;--nt-group-11-light: #000000;--nt-group-11-main-bg: #E6EE9C;--nt-group-11-dark-bg: #B3BC6D;--nt-group-11-light-bg: #FFFFCE;--nt-group-12-main: #000000;--nt-group-12-dark: #000000;--nt-group-12-light: #000000;--nt-group-12-main-bg: #FFF59D;--nt-group-12-dark-bg: #CBC26D;--nt-group-12-light-bg: #FFFFCF;--nt-group-13-main: #000000;--nt-group-13-dark: #000000;--nt-group-13-light: #000000;--nt-group-13-main-bg: #FFE082;--nt-group-13-dark-bg: #CAAE53;--nt-group-13-light-bg: #FFFFB3;--nt-group-14-main: #000000;--nt-group-14-dark: #000000;--nt-group-14-light: #000000;--nt-group-14-main-bg: #FFCC80;--nt-group-14-dark-bg: #CA9B52;--nt-group-14-light-bg: #FFFFB0;--nt-group-15-main: #000000;--nt-group-15-dark: #000000;--nt-group-15-light: #000000;--nt-group-15-main-bg: #FFAB91;--nt-group-15-dark-bg: #C97B63;--nt-group-15-light-bg: #FFDDC1;--nt-group-16-main: #000000;--nt-group-16-dark: #000000;--nt-group-16-light: #000000;--nt-group-16-main-bg: #BCAAA4;--nt-group-16-dark-bg: #8C7B75;--nt-group-16-light-bg: #EFDCD5;--nt-group-17-main: #000000;--nt-group-17-dark: #000000;--nt-group-17-light: #000000;--nt-group-17-main-bg: #EEEEEE;--nt-group-17-dark-bg: #BCBCBC;--nt-group-17-light-bg: #FFFFFF;--nt-group-18-main: #000000;--nt-group-18-dark: #000000;--nt-group-18-light: #000000;--nt-group-18-main-bg: #B0BEC5;--nt-group-18-dark-bg: #808E95;--nt-group-18-light-bg: #E2F1F8}.nt-group-0 .nt-plan-group-summary,.nt-group-0 .nt-timeline-dot{color:var(--nt-group-0-dark);background-color:var(--nt-group-0-dark-bg)}.nt-group-0 .period{color:var(--nt-group-0-main);background-color:var(--nt-group-0-main-bg)}.nt-group-1 .nt-plan-group-summary,.nt-group-1 .nt-timeline-dot{color:var(--nt-group-1-dark);background-color:var(--nt-group-1-dark-bg)}.nt-group-1 .period{color:var(--nt-group-1-main);background-color:var(--nt-group-1-main-bg)}.nt-group-2 .nt-plan-group-summary,.nt-group-2 .nt-timeline-dot{color:var(--nt-group-2-dark);background-color:var(--nt-group-2-dark-bg)}.nt-group-2 .period{color:var(--nt-group-2-main);background-color:var(--nt-group-2-main-bg)}.nt-group-3 .nt-plan-group-summary,.nt-group-3 .nt-timeline-dot{color:var(--nt-group-3-dark);background-color:var(--nt-group-3-dark-bg)}.nt-group-3 .period{color:var(--nt-group-3-main);background-color:var(--nt-group-3-main-bg)}.nt-group-4 .nt-plan-group-summary,.nt-group-4 .nt-timeline-dot{color:var(--nt-group-4-dark);background-color:var(--nt-group-4-dark-bg)}.nt-group-4 .period{color:var(--nt-group-4-main);background-color:var(--nt-group-4-main-bg)}.nt-group-5 .nt-plan-group-summary,.nt-group-5 .nt-timeline-dot{color:var(--nt-group-5-dark);background-color:var(--nt-group-5-dark-bg)}.nt-group-5 .period{color:var(--nt-group-5-main);background-color:var(--nt-group-5-main-bg)}.nt-group-6 .nt-plan-group-summary,.nt-group-6 .nt-timeline-dot{color:var(--nt-group-6-dark);background-color:var(--nt-group-6-dark-bg)}.nt-group-6 .period{color:var(--nt-group-6-main);background-color:var(--nt-group-6-main-bg)}.nt-group-7 .nt-plan-group-summary,.nt-group-7 .nt-timeline-dot{color:var(--nt-group-7-dark);background-color:var(--nt-group-7-dark-bg)}.nt-group-7 .period{color:var(--nt-group-7-main);background-color:var(--nt-group-7-main-bg)}.nt-group-8 .nt-plan-group-summary,.nt-group-8 .nt-timeline-dot{color:var(--nt-group-8-dark);background-color:var(--nt-group-8-dark-bg)}.nt-group-8 .period{color:var(--nt-group-8-main);background-color:var(--nt-group-8-main-bg)}.nt-group-9 .nt-plan-group-summary,.nt-group-9 .nt-timeline-dot{color:var(--nt-group-9-dark);background-color:var(--nt-group-9-dark-bg)}.nt-group-9 .period{color:var(--nt-group-9-main);background-color:var(--nt-group-9-main-bg)}.nt-group-10 .nt-plan-group-summary,.nt-group-10 .nt-timeline-dot{color:var(--nt-group-10-dark);background-color:var(--nt-group-10-dark-bg)}.nt-group-10 .period{color:var(--nt-group-10-main);background-color:var(--nt-group-10-main-bg)}.nt-group-11 .nt-plan-group-summary,.nt-group-11 .nt-timeline-dot{color:var(--nt-group-11-dark);background-color:var(--nt-group-11-dark-bg)}.nt-group-11 .period{color:var(--nt-group-11-main);background-color:var(--nt-group-11-main-bg)}.nt-group-12 .nt-plan-group-summary,.nt-group-12 .nt-timeline-dot{color:var(--nt-group-12-dark);background-color:var(--nt-group-12-dark-bg)}.nt-group-12 .period{color:var(--nt-group-12-main);background-color:var(--nt-group-12-main-bg)}.nt-group-13 .nt-plan-group-summary,.nt-group-13 .nt-timeline-dot{color:var(--nt-group-13-dark);background-color:var(--nt-group-13-dark-bg)}.nt-group-13 .period{color:var(--nt-group-13-main);background-color:var(--nt-group-13-main-bg)}.nt-group-14 .nt-plan-group-summary,.nt-group-14 .nt-timeline-dot{color:var(--nt-group-14-dark);background-color:var(--nt-group-14-dark-bg)}.nt-group-14 .period{color:var(--nt-group-14-main);background-color:var(--nt-group-14-main-bg)}.nt-group-15 .nt-plan-group-summary,.nt-group-15 .nt-timeline-dot{color:var(--nt-group-15-dark);background-color:var(--nt-group-15-dark-bg)}.nt-group-15 .period{color:var(--nt-group-15-main);background-color:var(--nt-group-15-main-bg)}.nt-group-16 .nt-plan-group-summary,.nt-group-16 .nt-timeline-dot{color:var(--nt-group-16-dark);background-color:var(--nt-group-16-dark-bg)}.nt-group-16 .period{color:var(--nt-group-16-main);background-color:var(--nt-group-16-main-bg)}.nt-group-17 .nt-plan-group-summary,.nt-group-17 .nt-timeline-dot{color:var(--nt-group-17-dark);background-color:var(--nt-group-17-dark-bg)}.nt-group-17 .period{color:var(--nt-group-17-main);background-color:var(--nt-group-17-main-bg)}.nt-group-18 .nt-plan-group-summary,.nt-group-18 .nt-timeline-dot{color:var(--nt-group-18-dark);background-color:var(--nt-group-18-dark-bg)}.nt-group-18 .period{color:var(--nt-group-18-main);background-color:var(--nt-group-18-main-bg)}.nt-error{border:2px dashed darkred;padding:0 1rem;background:#faf9ba;color:darkred}.nt-timeline{margin-top:30px}.nt-timeline .nt-timeline-title{font-size:1.1rem;margin-top:0}.nt-timeline .nt-timeline-sub-title{margin-top:0}.nt-timeline .nt-timeline-content{font-size:.8rem;border-bottom:2px dashed #ccc;padding-bottom:1.2rem}.nt-timeline.horizontal .nt-timeline-items{flex-direction:row;overflow-x:scroll}.nt-timeline.horizontal .nt-timeline-items>div{min-width:400px;margin-right:50px}.nt-timeline.horizontal.reverse .nt-timeline-items{flex-direction:row-reverse}.nt-timeline.horizontal.center .nt-timeline-before{background-image:linear-gradient(rgba(252, 70, 107, 0) 0%, rgb(252, 70, 107) 100%);background-repeat:no-repeat;background-size:100% 2px;background-position:0 center}.nt-timeline.horizontal.center .nt-timeline-after{background-image:linear-gradient(180deg, rgb(252, 70, 107) 0%, rgba(252, 70, 107, 0) 100%);background-repeat:no-repeat;background-size:100% 2px;background-position:0 center}.nt-timeline.horizontal.center .nt-timeline-items{background-image:radial-gradient(circle, rgb(63, 94, 251) 0%, rgb(252, 70, 107) 100%);background-repeat:no-repeat;background-size:100% 2px;background-position:0 center}.nt-timeline.horizontal .nt-timeline-dot{left:50%}.nt-timeline.horizontal .nt-timeline-dot:not(.bigger){top:calc(50% - 4px)}.nt-timeline.horizontal .nt-timeline-dot.bigger{top:calc(50% - 15px)}.nt-timeline.vertical .nt-timeline-items{flex-direction:column}.nt-timeline.vertical.reverse .nt-timeline-items{flex-direction:column-reverse}.nt-timeline.vertical.center .nt-timeline-before{background:linear-gradient(rgba(252, 70, 107, 0) 0%, rgb(252, 70, 107) 100%) no-repeat center/2px 100%}.nt-timeline.vertical.center .nt-timeline-after{background:linear-gradient(rgb(252, 70, 107) 0%, rgba(252, 70, 107, 0) 100%) no-repeat center/2px 100%}.nt-timeline.vertical.center .nt-timeline-items{background:radial-gradient(circle, rgb(63, 94, 251) 0%, rgb(252, 70, 107) 100%) no-repeat center/2px 100%}.nt-timeline.vertical.center .nt-timeline-dot{left:calc(50% - 10px)}.nt-timeline.vertical.center .nt-timeline-dot:not(.bigger){top:10px}.nt-timeline.vertical.center .nt-timeline-dot.bigger{left:calc(50% - 20px)}.nt-timeline.vertical.left{padding-left:100px}.nt-timeline.vertical.left .nt-timeline-item{padding-left:70px}.nt-timeline.vertical.left .nt-timeline-sub-title{left:-100px;width:100px}.nt-timeline.vertical.left .nt-timeline-before{background:linear-gradient(rgba(252, 70, 107, 0) 0%, rgb(252, 70, 107) 100%) no-repeat 30px/2px 100%}.nt-timeline.vertical.left .nt-timeline-after{background:linear-gradient(rgb(252, 70, 107) 0%, rgba(252, 70, 107, 0) 100%) no-repeat 30px/2px 100%}.nt-timeline.vertical.left .nt-timeline-items{background:radial-gradient(circle, rgb(63, 94, 251) 0%, rgb(252, 70, 107) 100%) no-repeat 30px/2px 100%}.nt-timeline.vertical.left .nt-timeline-dot{left:21px;top:8px}.nt-timeline.vertical.left .nt-timeline-dot.bigger{top:0px;left:10px}.nt-timeline.vertical.right{padding-right:100px}.nt-timeline.vertical.right .nt-timeline-sub-title{right:-100px;text-align:left;width:100px}.nt-timeline.vertical.right .nt-timeline-item{padding-right:70px}.nt-timeline.vertical.right .nt-timeline-before{background:linear-gradient(rgba(252, 70, 107, 0) 0%, rgb(252, 70, 107) 100%) no-repeat calc(100% - 30px)/2px 100%}.nt-timeline.vertical.right .nt-timeline-after{background:linear-gradient(rgb(252, 70, 107) 0%, rgba(252, 70, 107, 0) 100%) no-repeat calc(100% - 30px)/2px 100%}.nt-timeline.vertical.right .nt-timeline-items{background:radial-gradient(circle, rgb(63, 94, 251) 0%, rgb(252, 70, 107) 100%) no-repeat calc(100% - 30px)/2px 100%}.nt-timeline.vertical.right .nt-timeline-dot{right:21px;top:8px}.nt-timeline.vertical.right .nt-timeline-dot.bigger{top:10px;right:10px}.nt-timeline-items{display:flex;position:relative}.nt-timeline-items>div{min-height:100px;padding-top:2px;padding-bottom:20px}.nt-timeline-before{content:"";height:15px}.nt-timeline-after{content:"";height:60px;margin-bottom:20px}.nt-timeline-sub-title{position:absolute;width:50%;top:4px;font-size:18px;color:var(--nt-color-50)}[data-md-color-scheme=slate] .nt-timeline-sub-title{color:var(--nt-color-51)}.nt-timeline-item{position:relative}.nt-timeline.vertical.center:not(.alternate) .nt-timeline-item{padding-left:calc(50% + 40px)}.nt-timeline.vertical.center:not(.alternate) .nt-timeline-item .nt-timeline-sub-title{left:0;padding-right:40px;text-align:right}.nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(odd){padding-left:calc(50% + 40px)}.nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(odd) .nt-timeline-sub-title{left:0;padding-right:40px;text-align:right}.nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(even){text-align:right;padding-right:calc(50% + 40px)}.nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(even) .nt-timeline-sub-title{right:0;padding-left:40px;text-align:left}.nt-timeline-dot{position:relative;width:20px;height:20px;border-radius:100%;background-color:#fc5b5b;position:absolute;top:0px;z-index:2;display:flex;justify-content:center;align-items:center;box-shadow:0 2px 1px -1px rgba(0,0,0,.2),0 1px 1px 0 rgba(0,0,0,.14),0 1px 3px 0 rgba(0,0,0,.12);border:3px solid #fff}.nt-timeline-dot:not(.bigger) .icon{font-size:10px}.nt-timeline-dot.bigger{width:40px;height:40px;padding:3px}.nt-timeline-dot .icon{color:#fff}@supports not (-moz-appearance: none){details .nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(odd) .nt-timeline-sub-title,details .nt-timeline.vertical.center:not(.alternate) .nt-timeline-item .nt-timeline-sub-title{left:-40px}details .nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(even) .nt-timeline-sub-title{right:-40px}details .nt-timeline.vertical.center .nt-timeline-dot{left:calc(50% - 12px)}details .nt-timeline-dot.bigger{font-size:1rem !important}}.nt-timeline-item:nth-child(0) .nt-timeline-dot{background-color:var(--nt-color-0)}.nt-timeline-item:nth-child(1) .nt-timeline-dot{background-color:var(--nt-color-1)}.nt-timeline-item:nth-child(2) .nt-timeline-dot{background-color:var(--nt-color-2)}.nt-timeline-item:nth-child(3) .nt-timeline-dot{background-color:var(--nt-color-3)}.nt-timeline-item:nth-child(4) .nt-timeline-dot{background-color:var(--nt-color-4)}.nt-timeline-item:nth-child(5) .nt-timeline-dot{background-color:var(--nt-color-5)}.nt-timeline-item:nth-child(6) .nt-timeline-dot{background-color:var(--nt-color-6)}.nt-timeline-item:nth-child(7) .nt-timeline-dot{background-color:var(--nt-color-7)}.nt-timeline-item:nth-child(8) .nt-timeline-dot{background-color:var(--nt-color-8)}.nt-timeline-item:nth-child(9) .nt-timeline-dot{background-color:var(--nt-color-9)}.nt-timeline-item:nth-child(10) .nt-timeline-dot{background-color:var(--nt-color-10)}.nt-timeline-item:nth-child(11) .nt-timeline-dot{background-color:var(--nt-color-11)}.nt-timeline-item:nth-child(12) .nt-timeline-dot{background-color:var(--nt-color-12)}.nt-timeline-item:nth-child(13) .nt-timeline-dot{background-color:var(--nt-color-13)}.nt-timeline-item:nth-child(14) .nt-timeline-dot{background-color:var(--nt-color-14)}.nt-timeline-item:nth-child(15) .nt-timeline-dot{background-color:var(--nt-color-15)}.nt-timeline-item:nth-child(16) .nt-timeline-dot{background-color:var(--nt-color-16)}.nt-timeline-item:nth-child(17) .nt-timeline-dot{background-color:var(--nt-color-17)}.nt-timeline-item:nth-child(18) .nt-timeline-dot{background-color:var(--nt-color-18)}.nt-timeline-item:nth-child(19) .nt-timeline-dot{background-color:var(--nt-color-19)}.nt-timeline-item:nth-child(20) .nt-timeline-dot{background-color:var(--nt-color-20)}:root{--nt-scrollbar-color: #2751b0;--nt-plan-actions-height: 24px;--nt-units-background: #ff9800;--nt-months-background: #2751b0;--nt-plan-vertical-line-color: #a3a3a3ad}.nt-pastello{--nt-scrollbar-color: #9fb8f4;--nt-units-background: #f5dc82;--nt-months-background: #5b7fd1}[data-md-color-scheme=slate]{--nt-units-background: #003773}[data-md-color-scheme=slate] .nt-pastello{--nt-units-background: #3f4997}.nt-plan-root{min-height:200px;scrollbar-width:20px;scrollbar-color:var(--nt-scrollbar-color);display:flex}.nt-plan-root ::-webkit-scrollbar{width:20px}.nt-plan-root ::-webkit-scrollbar-track{box-shadow:inset 0 0 5px gray;border-radius:10px}.nt-plan-root ::-webkit-scrollbar-thumb{background:var(--nt-scrollbar-color);border-radius:10px}.nt-plan-root .nt-plan{flex:80%}.nt-plan-root.no-groups .nt-plan-periods{padding-left:0}.nt-plan-root.no-groups .nt-plan-group-summary{display:none}.nt-plan-root .nt-timeline-dot.bigger{top:-10px}.nt-plan-root .nt-timeline-dot.bigger[title]{cursor:help}.nt-plan{white-space:nowrap;overflow-x:auto;display:flex}.nt-plan .ug-timeline-dot{left:368px;top:-8px;cursor:help}.months{display:flex}.month{flex:auto;display:inline-block;box-shadow:rgba(0,0,0,.2) 0px 3px 1px -2px,rgba(0,0,0,.14) 0px 2px 2px 0px,rgba(0,0,0,.12) 0px 1px 5px 0px inset;background-color:var(--nt-months-background);color:#fff;text-transform:uppercase;font-family:Roboto,Helvetica,Arial,sans-serif;padding:2px 5px;font-size:12px;border:1px solid #000;width:150px;border-radius:8px}.nt-plan-group-activities{flex:auto;position:relative}.nt-vline{border-left:1px dashed var(--nt-plan-vertical-line-color);height:100%;left:0;position:absolute;margin-left:-0.5px;top:0;-webkit-transition:all .5s linear !important;-moz-transition:all .5s linear !important;-ms-transition:all .5s linear !important;-o-transition:all .5s linear !important;transition:all .5s linear !important;z-index:-2}.nt-plan-activity{display:flex;margin:2px 0;background-color:rgba(187,187,187,.2509803922)}.actions{height:var(--nt-plan-actions-height)}.actions{position:relative}.period{display:inline-block;height:var(--nt-plan-actions-height);width:120px;position:absolute;left:0px;background:#1da1f2;border-radius:5px;transition:all .5s;cursor:help;-webkit-transition:width 1s ease-in-out;-moz-transition:width 1s ease-in-out;-o-transition:width 1s ease-in-out;transition:width 1s ease-in-out}.period .nt-tooltip{display:none;top:30px;position:relative;padding:1rem;text-align:center;font-size:12px}.period:hover .nt-tooltip{display:inline-block}.period-0{left:340px;visibility:visible;background-color:#456165}.period-1{left:40px;visibility:visible;background-color:green}.period-2{left:120px;visibility:visible;background-color:pink;width:80px}.period-3{left:190px;visibility:visible;background-color:darkred;width:150px}.weeks>span,.days>span{height:25px}.weeks>span{display:inline-block;margin:0;padding:0;font-weight:bold}.weeks>span .week-text{font-size:10px;position:absolute;display:inline-block;padding:3px 4px}.days{z-index:-2;position:relative}.day-text{font-size:10px;position:absolute;display:inline-block;padding:3px 4px}.period span{font-size:12px;vertical-align:top;margin-left:4px;color:#000;background:rgba(255,255,255,.6588235294);border-radius:6px;padding:0 4px}.weeks,.days{height:20px;display:flex;box-sizing:content-box}.months{display:flex}.week,.day{height:20px;position:relative;border:1;flex:auto;border:2px solid #fff;border-radius:4px;background-color:var(--nt-units-background);cursor:help}.years{display:flex}.year{text-align:center;border-right:1px solid var(--nt-plan-vertical-line-color);font-weight:bold}.year:first-child{border-left:1px solid var(--nt-plan-vertical-line-color)}.year:first-child:last-child{width:100%}.quarters{display:flex}.quarter{width:12.5%;text-align:center;border-right:1px solid var(--nt-plan-vertical-line-color);font-weight:bold}.quarter:first-child{border-left:1px solid var(--nt-plan-vertical-line-color)}.nt-plan-group{margin:20px 0;position:relative}.nt-plan-group{display:flex}.nt-plan-group-summary{background:#2751b0;width:150px;white-space:normal;padding:.1rem .5rem;border-radius:5px;color:#fff;z-index:3}.nt-plan-group-summary p{margin:0;padding:0;font-size:.6rem;color:#fff}.nt-plan-group-summary,.month,.period,.week,.day,.nt-tooltip{border:3px solid #fff;box-shadow:0 2px 3px -1px rgba(0,0,0,.2),0 3px 3px 0 rgba(0,0,0,.14),0 1px 5px 0 rgba(0,0,0,.12)}.nt-plan-periods{padding-left:150px}.months{z-index:2;position:relative}.weeks{position:relative;top:-2px;z-index:0}.month,.quarter,.year,.week,.day,.nt-tooltip{font-family:Roboto,Helvetica,Arial,sans-serif;box-sizing:border-box}.nt-cards.nt-grid{display:grid;grid-auto-columns:1fr;gap:.5rem;max-width:100vw;overflow-x:auto;padding:1px}.nt-cards.nt-grid.cols-1{grid-template-columns:repeat(1, 1fr)}.nt-cards.nt-grid.cols-2{grid-template-columns:repeat(2, 1fr)}.nt-cards.nt-grid.cols-3{grid-template-columns:repeat(3, 1fr)}.nt-cards.nt-grid.cols-4{grid-template-columns:repeat(4, 1fr)}.nt-cards.nt-grid.cols-5{grid-template-columns:repeat(5, 1fr)}.nt-cards.nt-grid.cols-6{grid-template-columns:repeat(6, 1fr)}@media only screen and (max-width: 400px){.nt-cards.nt-grid{grid-template-columns:repeat(1, 1fr) !important}}.nt-card{box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12)}.nt-card:hover{box-shadow:0 2px 2px 0 rgba(0,0,0,.24),0 3px 1px -2px rgba(0,0,0,.3),0 1px 5px 0 rgba(0,0,0,.22)}[data-md-color-scheme=slate] .nt-card{box-shadow:0 2px 2px 0 rgba(4,40,33,.14),0 3px 1px -2px rgba(40,86,94,.47),0 1px 5px 0 rgba(139,252,255,.64)}[data-md-color-scheme=slate] .nt-card:hover{box-shadow:0 2px 2px 0 rgba(0,255,206,.14),0 3px 1px -2px rgba(33,156,177,.47),0 1px 5px 0 rgba(96,251,255,.64)}.nt-card>a{color:var(--md-default-fg-color)}.nt-card>a>div{cursor:pointer}.nt-card{padding:5px;margin-bottom:.5rem}.nt-card-title{font-size:1rem;font-weight:bold;margin:4px 0 8px 0;line-height:22px}.nt-card-content{padding:.4rem .8rem .8rem .8rem}.nt-card-text{font-size:14px;padding:0;margin:0}.nt-card .nt-card-image{text-align:center;border-radius:2px;background-position:center center;background-size:cover;background-repeat:no-repeat;min-height:120px}.nt-card .nt-card-image.tags img{margin-top:12px}.nt-card .nt-card-image img{height:105px;margin-top:5px}.nt-card a:hover,.nt-card a:focus{color:var(--md-accent-fg-color)}.nt-card h2{margin:0}.span-table-wrapper table{border-collapse:collapse;margin-bottom:2rem;border-radius:.1rem}.span-table td,.span-table th{padding:.2rem;background-color:var(--md-default-bg-color);font-size:.64rem;max-width:100%;overflow:auto;touch-action:auto;border-top:.05rem solid var(--md-typeset-table-color);padding:.9375em 1.25em;vertical-align:top}.span-table tr:first-child td{font-weight:700;min-width:5rem;padding:.9375em 1.25em;vertical-align:top}.span-table td:first-child{border-left:.05rem solid var(--md-typeset-table-color)}.span-table td:last-child{border-right:.05rem solid var(--md-typeset-table-color)}.span-table tr:last-child{border-bottom:.05rem solid var(--md-typeset-table-color)}.span-table [colspan],.span-table [rowspan]{font-weight:bold;border:.05rem solid var(--md-typeset-table-color)}.span-table tr:not(:first-child):hover td:not([colspan]):not([rowspan]),.span-table td[colspan]:hover,.span-table td[rowspan]:hover{background-color:rgba(0,0,0,.035);box-shadow:0 .05rem 0 var(--md-default-bg-color) inset;transition:background-color 125ms}.nt-contribs{margin-top:2rem;font-size:small;border-top:1px dotted #d3d3d3;padding-top:.5rem}.nt-contribs .nt-contributors{padding-top:.5rem;display:flex;flex-wrap:wrap}.nt-contribs .nt-contributor{background:#d3d3d3;background-size:cover;width:40px;height:40px;border-radius:100%;margin:0 6px 6px 0;cursor:help;opacity:.7}.nt-contribs .nt-contributor:hover{opacity:1}.nt-contribs .nt-contributors-title{font-style:italic;margin-bottom:0}.nt-contribs .nt-initials{text-transform:uppercase;font-size:24px;text-align:center;width:40px;height:40px;display:inline-block;vertical-align:middle;position:relative;top:2px;color:inherit;font-weight:bold}.nt-contribs .nt-group-0{background-color:var(--nt-color-0)}.nt-contribs .nt-group-1{background-color:var(--nt-color-1)}.nt-contribs .nt-group-2{background-color:var(--nt-color-2)}.nt-contribs .nt-group-3{background-color:var(--nt-color-3)}.nt-contribs .nt-group-4{background-color:var(--nt-color-4)}.nt-contribs .nt-group-5{background-color:var(--nt-color-5)}.nt-contribs .nt-group-6{background-color:var(--nt-color-6)}.nt-contribs .nt-group-7{color:#000;background-color:var(--nt-color-7)}.nt-contribs .nt-group-8{color:#000;background-color:var(--nt-color-8)}.nt-contribs .nt-group-9{background-color:var(--nt-color-9)}.nt-contribs .nt-group-10{background-color:var(--nt-color-10)}.nt-contribs .nt-group-11{background-color:var(--nt-color-11)}.nt-contribs .nt-group-12{background-color:var(--nt-color-12)}.nt-contribs .nt-group-13{background-color:var(--nt-color-13)}.nt-contribs .nt-group-14{background-color:var(--nt-color-14)}.nt-contribs .nt-group-15{color:#000;background-color:var(--nt-color-15)}.nt-contribs .nt-group-16{background-color:var(--nt-color-16)}.nt-contribs .nt-group-17{color:#000;background-color:var(--nt-color-17)}.nt-contribs .nt-group-18{background-color:var(--nt-color-18)}.nt-contribs .nt-group-19{background-color:var(--nt-color-19)}.nt-contribs .nt-group-20{color:#000;background-color:var(--nt-color-20)}.nt-contribs .nt-group-21{color:#000;background-color:var(--nt-color-21)}.nt-contribs .nt-group-22{color:#000;background-color:var(--nt-color-22)}.nt-contribs .nt-group-23{color:#000;background-color:var(--nt-color-23)}.nt-contribs .nt-group-24{color:#000;background-color:var(--nt-color-24)}.nt-contribs .nt-group-25{color:#000;background-color:var(--nt-color-25)}.nt-contribs .nt-group-26{color:#000;background-color:var(--nt-color-26)}.nt-contribs .nt-group-27{background-color:var(--nt-color-27)}.nt-contribs .nt-group-28{color:#000;background-color:var(--nt-color-28)}.nt-contribs .nt-group-29{color:#000;background-color:var(--nt-color-29)}.nt-contribs .nt-group-30{background-color:var(--nt-color-30)}.nt-contribs .nt-group-31{background-color:var(--nt-color-31)}.nt-contribs .nt-group-32{color:#000;background-color:var(--nt-color-32)}.nt-contribs .nt-group-33{background-color:var(--nt-color-33)}.nt-contribs .nt-group-34{background-color:var(--nt-color-34)}.nt-contribs .nt-group-35{background-color:var(--nt-color-35)}.nt-contribs .nt-group-36{background-color:var(--nt-color-36)}.nt-contribs .nt-group-37{background-color:var(--nt-color-37)}.nt-contribs .nt-group-38{background-color:var(--nt-color-38)}.nt-contribs .nt-group-39{color:#000;background-color:var(--nt-color-39)}.nt-contribs .nt-group-40{color:#000;background-color:var(--nt-color-40)}.nt-contribs .nt-group-41{color:#000;background-color:var(--nt-color-41)}.nt-contribs .nt-group-42{color:#000;background-color:var(--nt-color-42)}.nt-contribs .nt-group-43{color:#000;background-color:var(--nt-color-43)}.nt-contribs .nt-group-44{color:#000;background-color:var(--nt-color-44)}.nt-contribs .nt-group-45{background-color:var(--nt-color-45)}.nt-contribs .nt-group-46{color:#000;background-color:var(--nt-color-46)}.nt-contribs .nt-group-47{background-color:var(--nt-color-47)}.nt-contribs .nt-group-48{background-color:var(--nt-color-48)}.nt-contribs .nt-group-49{background-color:var(--nt-color-49)} diff --git a/rodi/docs/dependency-inversion.md b/rodi/docs/dependency-inversion.md new file mode 100644 index 0000000..943cfce --- /dev/null +++ b/rodi/docs/dependency-inversion.md @@ -0,0 +1,320 @@ +This page describes how to apply the [_Dependency Inversion Principle_](./getting-started.md#dependency-inversion-principle), working with _abstract_ classes, protocols, +and generics. + +- [X] Working with interfaces. +- [X] Using abstract classes and protocols. +- [X] Working with generics. + +## Working with interfaces + +Abstract types are a way to define a common interface for a set of classes. This allows +you to write code that works with any class that implements the interface, without +needing to know the details of the implementation. When registering a type in a +`Container`, you can specify the base _interface_ which is used as _key_ to resolve +_concrete_ types, and the implementation type which is used to create the instance. This +is useful when it is desirable to use the same interface for different implementations, +or when you want to switch to a different implementation in the future without changing +the code that relies on the interface. + +=== "add_transient" + + ```python {linenums="1", hl_lines="9 15 17"} + from abc import ABC, abstractmethod + from rodi import Container + + class MyInterface(ABC): + @abstractmethod + def do_something(self) -> str: + pass + + class MyClass(MyInterface): + def do_something(self) -> str: + return "Hello, world!" + + container = Container() + + container.add_transient(MyInterface, MyClass) + + a1 = container.resolve(MyInterface) + assert isinstance(a1, MyClass) + assert a1.do_something() == "Hello, world!" + ``` + +=== "add_singleton" + + ```python {linenums="1", hl_lines="9 15 17"} + from abc import ABC, abstractmethod + from rodi import Container + + class MyInterface(ABC): + @abstractmethod + def do_something(self) -> str: + pass + + class MyClass(MyInterface): + def do_something(self) -> str: + return "Hello, world!" + + container = Container() + + container.add_singleton(MyInterface, MyClass) + + a1 = container.resolve(MyInterface) + assert isinstance(a1, MyClass) + assert a1.do_something() == "Hello, world!" + ``` + +=== "add_scoped" + + ```python {linenums="1", hl_lines="9 15 17"} + from abc import ABC, abstractmethod + from rodi import Container + + class MyInterface(ABC): + @abstractmethod + def do_something(self) -> str: + pass + + class MyClass(MyInterface): + def do_something(self) -> str: + return "Hello, world!" + + container = Container() + + container.add_scoped(MyInterface, MyClass) + + a1 = container.resolve(MyInterface) + assert isinstance(a1, MyClass) + assert a1.do_something() == "Hello, world!" + ``` + +Using [`ABC` and `abstractmethod`](https://docs.python.org/3/library/abc.html) +is not strictly necessary, but it is recommended for defining interfaces. +This ensures that any class implementing the interface has the required methods. + +If you decide on using a normal class to describe the interface, Rodi requires the +concrete class to be a subclass of the interface. + +Otherwise, you can use a [`Protocol`](https://peps.python.org/pep-0544/) from the +`typing` module to define the interface. In this case, Rodi allows registering a +protocol as the interface and a normal class that does not inherit it (which aligns with +the original purpose of Python's `Protocol`). + +The following examples work: + +=== "Regular class (requires subclassing)" + + ```python {linenums="1", hl_lines="9 16 18"} + from rodi import Container + + + class MyInterface: + def do_something(self) -> str: + pass + + + class MyClass(MyInterface): + def do_something(self) -> str: + return "Hello, world!" + + + container = Container() + + container.add_transient(MyInterface, MyClass) + + a1 = container.resolve(MyInterface) + assert isinstance(a1, MyClass) + assert a1.do_something() == "Hello, world!" + print(a1) + ``` + +=== "Protocol (does not require subclassing)" + + ```python {linenums="1", hl_lines="10 17 19"} + from typing import Protocol + from rodi import Container + + + class MyInterface(Protocol): + def do_something(self) -> str: + pass + + + class MyClass: + def do_something(self) -> str: + return "Hello, world!" + + + container = Container() + + container.add_transient(MyInterface, MyClass) + + a1 = container.resolve(MyInterface) + assert isinstance(a1, MyClass) + assert a1.do_something() == "Hello, world!" + print(a1) + ``` + +Rodi raises an exception if we try registering a normal class as interface, with a +concrete class that does not inherit it. + +/// admonition | Protocols validation. + type: warning + +Rodi does **not** validate implementations of Protocols. This means that if you register +a class that does not implement the methods of the Protocol, Rodi will not raise an +exception. Support for Protocols validation might be added in the future, but for now, +you should ensure that the classes you register do implement the methods of the +Protocol. +/// + +--- + +## Note about factories + +When using factories to define how abstract types are created, ensure the +factory's return type annotation specifies the _interface_. + +```python {linenums="1", hl_lines="13-14 18"} +from abc import ABC, abstractmethod +from rodi import Container + +class MyInterface(ABC): + @abstractmethod + def do_something(self) -> str: + pass + +class MyClass(MyInterface): + def do_something(self) -> str: + return "Hello, world!" + +def my_factory() -> MyInterface: + return MyClass() + +container = Container() + +container.add_transient_by_factory(my_factory) + +a1 = container.resolve(A) +a2 = container.resolve(A) +assert isinstance(a1, A) +assert isinstance(a2, A) +assert a1 is not a2 +``` + +/// admonition | Note about key types. + type: danger + +When working with abstract types, the _interface_ type (or _protocol_) must always be +used as the _key_ type. The implementation type is used to create the instance, but it +is not used as a key to resolve the type. This is according to the [_Dependency +Inversion Principle_](./getting-started.md#dependency-inversion-principle), which states +that high-level modules should not depend on low-level modules, but both should depend +on abstractions. + +This is conceptually wrong: + +```python {linenums="1", hl_lines="10"} +class MyInterface(ABC): + @abstractmethod + def do_something(self) -> str: + pass + +class MyClass(MyInterface): + def do_something(self) -> str: + return "Hello, world!" + +def my_factory() -> MyClass: # <-- No. This is a mistake. + return MyClass() + +container.add_transient_by_factory(my_factory) # <-- MyClass is used as Key. +``` + +/// + +## Working with generics + +Generic types are supported. + +```python {linenums="1", hl_lines="1 6 9 29 34 40-41 44-45"} +from typing import Generic, TypeVar + +from rodi import Container + + +T = TypeVar("T") + + +class LoggedVar(Generic[T]): + def __init__(self, value: T, name: str): + self.name = name + self.value = value + + def set(self, new: T): + self.log("Set " + repr(self.value)) + self.value = new + + def get(self) -> T: + self.log("Get " + repr(self.value)) + return self.value + + def log(self, message: str): + print(self.name, message) + + +container = Container() + + +class A(LoggedVar[int]): + def __init__(self): + super().__init__(10, "example") + + +class B(LoggedVar[str]): + def __init__(self): + super().__init__("Foo", "example") + + +class C: + a: LoggedVar[int] + b: LoggedVar[str] + + +container.add_scoped(LoggedVar[int], A) +container.add_scoped(LoggedVar[str], B) +container.add_scoped(C) + +instance = container.resolve(C) + +assert isinstance(instance.a, A) +assert isinstance(instance.b, B) +``` + +As described above, use the *most* abstract class as the key to resolve more +*concrete* types, in accordance with the Dependency Inversion Principle (DIP). Generics are the **most** abstract +type, so use them as keys like in the example above at lines _44-45_. + +## Checking if a type is registered + +To check if a type is registered in the container, use the `__contains__` interface: + +```python {linenums="1", hl_lines="11-12"} +from rodi import Container + +class A: ... + +class B: ... + +container = Container() + +container.add_transient(A) + +assert A in container # True +assert B not in container # True +``` + +This can be useful to support alternative ways to register types. For example, tests +code can register a mock type for a class, and the code under test can check if any +interface is already registered in the container, and skip the registration if it is. + +The next page explains how to work with [async](./async.md). diff --git a/rodi/docs/errors.md b/rodi/docs/errors.md new file mode 100644 index 0000000..7021c17 --- /dev/null +++ b/rodi/docs/errors.md @@ -0,0 +1,94 @@ +This page describes errors and custom exceptions raised by Rodi. + +## Errors + +| Name | Description | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CannotResolveTypeException` | This error is raised when a type cannot be resolved because it was not registered in the container. | +| `CircularDependencyException` | This error is raised when a circular dependency is detected and cannot be resolved. | +| `FactoryMissingContextException` | This error is raised when a factory function does not have `_locals`. This generally happens only if classes are defined inside functions and use locals (not a common case). | +| `MissingTypeException` | This error is raised when a factory function does not specify its return type annotation, and the user does not specify the type it returns. | +| `OverridingServiceException` | This error is raised when the user tries to override a type that is already registered in the container. | + +## Cannot resolve type + +```python {linenums="1", hl_lines="11"} +from rodi import Container + + +class A: ... + +class B: + dependency: A + + +container = Container() +container.register(B) + +container.resolve(B) # <-- raises exception 💥 +``` + +`rodi.CannotResolveParameterException: Unable to resolve parameter 'dependency' when resolving 'B'` + +All dependencies must be explicitly registered in the container. To resolve the +error, register the missing type: + +```python {linenums="1", hl_lines="11-12"} +from rodi import Container + + +class A: ... + +class B: + dependency: A + + +container = Container() +container.register(A) +container.register(B) + +container.resolve(B) +``` + +## The chicken and egg problem :chicken: :egg: + +The following classes have a circular dependency: + +```python +class Chicken: + egg: "Egg" + + +class Egg: + chicken: Chicken +``` + +If we try to have Rodi resolve them automatically, we get an error: + +```python {linenums="1", hl_lines="4-5 8-9 17"} +from rodi import Container + + +class Chicken: + egg: "Egg" + + +class Egg: + chicken: Chicken + + +container = Container() +container.register(Chicken) +container.register(Egg) + +# The following line raises an exception: +chicken = container.resolve(Chicken) # 💥 +``` + +The raised error is: + +` raise CircularDependencyException(chain[0], concrete_type) +rodi.CircularDependencyException: A circular dependency was detected for the service of type 'Chicken' for 'Egg'`. + +Rodi cannot infer automatically which type should be instantiated first: +_Chicken_ or _Egg_? diff --git a/rodi/docs/getting-started.md b/rodi/docs/getting-started.md new file mode 100644 index 0000000..e598bff --- /dev/null +++ b/rodi/docs/getting-started.md @@ -0,0 +1,697 @@ +# Getting started with Rodi + +This page introduces the basics of using Rodi, including: + +- [X] An overview of dependency injection. +- [X] The use cases Rodi is intended for. + +## Overview of dependency injection + +Consider the following example: + +```python +class A: + ... + + +class B: + def __init__(self, dependency: A): + self.dependency = dependency +``` + +The type `B` depends upon the type `A`, because it requires an instance of `A` +in its constructor. In other words, `A` is a _dependency_ of `B`. + +For a more concrete example, consider the following: + +```python +class ProductsRepository: + """Provides methods to read, write, and delete products information.""" + + +class ProductsService: + """Provides business logic for managing products.""" + + def __init__(self, repository: ProductsRepository): + self.repository = repository +``` + +The `ProductsService` requires an instance of `ProductsRepository`. The former +handles business logic, while the latter defines a type responsible for +storing, reading, and deleting product information. + +Imagine we also need to send emails when certain events happen, the +`ProductsService` would likely have an additional dependency: + +```python +class ProductsService: + """Provides business logic for managing products.""" + + def __init__( + self, + repository: ProductsRepository, + email_handler: EmailHandler + ): + self.repository = repository + self.email_handler = email_handler +``` + +Encapsulating the code that performs data access operations (`ProductsRepository`) +and that sends emails (`EmailHandler`) into dedicated classes is the right +approach, as the same functionality can be reused in other services (e.g., +_OrdersService, AccountsService_) without duplicating code. + +--- + +Dependencies _could also_ be instantiated by the classes that need them: + +```python +class ProductsService: + """Provides business logic for managing products.""" + + def __init__(self): + self.repository = ProductsRepository() + self.email_handler = EmailHandler() +``` + +However, this approach has several limitations. + +- **Scalability Issues**: As the application grows, managing dependencies + manually within classes becomes cumbersome. It can lead to duplicated code + and make the system harder to maintain. As dependencies are likely to require + their own set of parameters passed to their constructors, the parent + constructor would become more and more complex. +- **Tight Coupling**: The `ProductsService` class is tightly coupled to + _concrete_ implementations of its dependencies. This makes it less convenient + to replace `ProductsRepository` and `EmailHandler` with different + implementations (e.g., a mock for testing or a different database backend). +- **Reduced Testability**: Since dependencies are instantiated within the + class, it is necessary to modify the properties of instances of + `ProductsService`, to replace them with mocks or stubs during unit testing. +- **Lack of Flexibility**: If the application needs to use a different + implementation of `ProductsRepository` (e.g., for different environments or + configurations), the source code of the `ProductsService` class must be + modified. +- **Code Duplication**: If multiple classes need the same dependency, each + class would need to instantiate it, leading to duplicated code and increased + maintenance overhead. +- **Configuration Management**: Managing configuration settings (e.g., database + connection strings or API keys) becomes harder because they are scattered + across multiple classes instead of being centralized. +- **Runtime Flexibility**: Instantiating dependencies directly in the class + makes it harder to dynamically change or configure dependencies at runtime + (e.g., switching to a different implementation based on environment + variables). + +Alternatively, dependencies could be instantiated at the module level and +managed as global variables. +Instantiating dependencies as globals at module level is generally not ideal, +as it leads to: + +- **Tight Coupling to Global State**: When dependencies are global, any part of + the application can access and modify them. This makes the code tightly + coupled to the global state, leading to unpredictable behavior and bugs that + are hard to trace. +- **Reduced Testability**: Global dependencies make unit testing difficult + because tests cannot easily isolate or mock dependencies. Each test might + inadvertently affect or be affected by the global state, leading to flaky + tests. + +Dependency injection can help addressing the problems listed above. + +### Inversion of Control + +**Inversion of Control (IoC)** is a design principle in which the control of +object creation and dependency management is inverted from the class itself to +an external entity, such as a framework or container. Instead of a class +instantiating its dependencies directly, they are provided to the class from +the outside. This promotes loose coupling and enhances testability. +**Dependency Injection** is a common implementation of IoC. + +### Dependency Injection + +**Dependency Injection** is a design principle where a class does not create +its own dependencies. Instead, the dependencies are provided (or "injected") +into the class from the outside. This makes the class more flexible, easier to +test, and less dependent on specific implementations. + +If we consider again the classes `A` and `B` described earlier, they can be +registered and resolved using Rodi this way: + +```python +# example1.py +class A: + ... + + +class B: + def __init__(self, dependency: A): + self.dependency = dependency +``` + +```python +# main.py +from example1 import A, B + +from rodi import Container + + +container = Container() + +# register types: +container.add_transient(A) +container.add_transient(B) + +# resolve B +example = container.resolve(B) + +# the container automatically resolves +assert isinstance(example, B) +assert isinstance(example.dependency, A) +``` + +/// admonition | Completely non-intrusive. + type: tip + +Notice that Rodi is completely non-intrusive and does **not** require any changes to the +source code of the types it handles. This was one of the library's primary design goals. +/// + +In this example, both `A` and `B` are concrete types. Rodi can resolve concrete +types without any issues. However, the true power of dependency injection +becomes evident when we use _abstract types_ or _interfaces_ to define +dependencies. Let's talk about the _Dependency Inversion Principle_. + +### Dependency Inversion Principle + +The **Dependency Inversion Principle (DIP)** is a design principle that says +high-level modules (like business logic) should not depend on low-level modules +(like database access). Instead, _both_ should depend on abstractions, like +interfaces or abstract classes. This makes the code more flexible and easier +to change because you can swap out the low-level details without affecting the +high-level logic. Inversion of Control aligns with the Dependency Inversion +Principle. + +Consider the following example, of `ProductsService`, `ProductsRepository`, +and `SQLProductsRepository`. + +```python {linenums="1", hl_lines="14-15 34-35"} +# domain/products.py +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class Product: + id: int + name: str + description: str + price: float + + +# Abstraction: ProductsRepository +class ProductsRepository(ABC): + """ + Abstract base class for product repositories. + Defines the interface for data access operations. + """ + + @abstractmethod + def get_all_products(self) -> list[Product]: + """Retrieve all products.""" + + @abstractmethod + def get_product_by_id(self, product_id: int) -> Product | None: + """Retrieve a product by its ID.""" + + @abstractmethod + def create_product(self, product: Product) -> int: + """Create a new product.""" + + +# High-level module: ProductsService +class ProductsService: + """ + Provides business logic for managing products. + Depends on an abstract ProductsRepository. + """ + + def __init__(self, repository: ProductsRepository): + self.repository = repository + + def get_all_products(self) -> list[Product]: + """Retrieve all products.""" + return self.repository.get_all_products() + + def get_product_by_id(self, product_id: int) -> Product | None: + """Retrieve a product by its ID.""" + return self.repository.get_product_by_id(product_id) + + def create_product(self, product: Product) -> int: + """Create a new product.""" + return self.repository.create_product(product) + +``` + +```python {linenums="1", hl_lines="5-6"} +# data/sql/products.py +from domain.products import Product, ProductsRepository + + +# Low-level module: SQLProductsRepository +class SQLProductsRepository(ProductsRepository): + """ + Concrete implementation of ProductsRepository using a SQL database. + """ + + def __init__(self, db_connection): + self.db_connection = db_connection + + def get_all_products(self) -> list[Product]: + """Retrieve all products from the database.""" + cursor = self.db_connection.cursor() + cursor.execute("SELECT id, name, description, price FROM products") + rows = cursor.fetchall() + return [ + Product(id=row[0], name=row[1], description=row[2], price=row[3]) + for row in rows + ] + + def get_product_by_id(self, product_id: int) -> Product | None: + """Retrieve a product by its ID.""" + cursor = self.db_connection.cursor() + cursor.execute( + "SELECT id, name, description, price FROM products WHERE id = ?", + (product_id,), + ) + row = cursor.fetchone() + if row: + return Product(id=row[0], name=row[1], description=row[2], price=row[3]) + return None + + def create_product(self, product: Product) -> int: + """Insert a new product into the database.""" + cursor = self.db_connection.cursor() + cursor.execute( + "INSERT INTO products (name, description, price) VALUES (?, ?, ?)", + (product.name, product.description, product.price), + ) + self.db_connection.commit() + return cursor.lastrowid +``` + +**Explanation:** + +- The **abstraction `ProductsRepository`** defines the interface for data + access operations. +- The **high-level class (`ProductsService`)** depends on this abstraction, not + on concrete implementations. +- The **high-level class (`ProductsService`)** implements business logic and + depends on the `ProductsRepository` abstraction. +- `ProductsService` does not depend on the details of how data is stored or + retrieved. +- The low-level class (`SQLProductsRepository`) implements the + `ProductsRepository` interface using an SQL database. +- It can be swapped out for another implementation (e.g., + `InMemoryProductsRepository`) without modifying the `ProductsService`. + +```mermaid +classDiagram + class ProductsRepository { + <> + +get_all_products() list~Product~ + +get_product_by_id(product_id: int) Product | None + +create_product(product: Product) int + } + + class SQLProductsRepository { + +get_all_products() list~Product~ + +get_product_by_id(product_id: int) Product | None + +create_product(product: Product) int + } + + class ProductsService { + -repository: ProductsRepository + +get_all_products() list~Product~ + +get_product_by_id(product_id: int) Product | None + +create_product(product: Product) int + } + + ProductsRepository <-- SQLProductsRepository : implements + ProductsService --> ProductsRepository : depends on +``` + +The benefits of DIP are: + +- **Loose Coupling**: The `ProductsService` is decoupled from the specific + implementation of the repository. +- **Flexibility**: You can easily replace `SQLProductsRepository` with another + implementation (e.g., a mock for testing). +- **Testability**: The `ProductsService` can be tested independently by + injecting a mock or stub implementation of `ProductsRepository`. + +To better understand the concept, consider the following example that shows how +those classes can be imported and instantiated: + +```python {linenums="1", hl_lines="19-20 22-23"} +import sqlite3 + +from data.sql.products import SQLProductsRepository +from domain.products import Product, ProductsService + +# Set up an SQLite database connection +connection = sqlite3.connect(":memory:") +connection.execute( + """ + CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + price REAL NOT NULL + ) + """ +) + +# Instantiate the low-level module (SQLProductsRepository) +sql_repository = SQLProductsRepository(connection) + +# Instantiate the high-level module (ProductsService) +service = ProductsService(sql_repository) + +# Use the service +new_product = Product( + id=0, name="Laptop", description="A powerful laptop", price=1200.00 +) +product_id = service.create_product(new_product) +print(service.get_product_by_id(product_id)) +print(service.get_all_products()) +``` + +As the number of dependencies grow, the code that instantiates objects can +easily become hard to maintain. To simplify the management of dependencies and +reduce the complexity of object instantiation, we can leverage a dependency +injection framework like Rodi. + +### The Repository pattern example + +The three classes described above: `ProductsService`, `ProductsRepository`, and +`SQLProductsRepository`, can be wired using Rodi this way: + +```python {linenums="1", hl_lines="9 12 28-29 31 36"} +import sqlite3 + +from rodi import Container + +from data.sql.products import SQLProductsRepository +from domain.products import Product, ProductsRepository, ProductsService + + +container = Container() + + +def connection_factory() -> sqlite3.Connection: + """Create a new SQLite database connection.""" + conn = sqlite3.connect(":memory:") + conn.execute( + """ + CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + price REAL NOT NULL + ) + """ + ) + return conn + + +container.add_transient_by_factory(connection_factory) +container.add_alias("db_connection", sqlite3.Connection) + +container.add_transient(ProductsRepository, SQLProductsRepository) +container.add_transient(ProductsService) + + +# Obtain an instance of the service +service = container.resolve(ProductsService) + +# Use the service +new_product = Product( + id=0, name="Laptop", description="A powerful laptop", price=1200.00 +) +product_id = service.create_product(new_product) +print(service.get_product_by_id(product_id)) +print(service.get_all_products()) +``` + +Some interesting things are happening in this code: + +- At line _9_, an instance of `rodi.Container` is created. This class is used + to register the types that must be resolved, and to resolve those types. +- It was not necessary to modify the source code of the classes being handled: + Rodi inspects the code of registered types to know how to resolve them. +- A factory function is used to define how the instance of `sqlite3.Connection` + is to be created. This is convenient because the `connect` method, which + returns an instance of that class, requires a `str`, and resolving base types + with `DI` is not a good idea. +- The factory has a return type annotation: Rodi uses that type annotation + as the _key type_ that is resolved using the factory function. Note that a factory + might declare a more **abstract** type than the one it returns (following the DIP + principle). +- Since the constructor of the `SQLProductsRepository` class does not include a type + annotation for its `db_connection` dependency, an alias is configured at line _29_ to + instruct the container to resolve parameters named `db_connection` as instances of + `sqlite3.Connection`. Alternatively, we could have updated the source code of + `SQLProductsRepository` to include a type annotation in its constructor. +- At line _31_, the **abstract** type `ProductsRepository` is registered, + instructing the container to resolve that type with the **concrete** + implementation `SQLProductsRepository`. According to the **DIP** principle, + when registering an abstract type and its implementation, Rodi requires + using the abstract type as _key_. +- At line _32_, the `ProductsService` type is also registered, because this is + required to build the graph of dependencies. +- At line _36_, an instance of `ProductsService` is obtained through **DI**. + Since this is the first time the `Container` needs to resolve a type, it runs + code inspections to build the tree of dependencies. These code inspections + are executed only once, unless new types are registered in the same + `Container`. The container obtains all necessary objects: from the + `db_connection` and the `SQLProductsRepository` to resolve the **abstract** + dependency `ProductsRepository`, used to instantiate the requested + `ProductsService`. + +## Rodi's use cases + +Rodi is designed to simplify objects instantiation and dependency management. It can +inspect constructors (`__init__` methods) and class properties to automatically resolve +dependencies. + +Support for inspecting class properties is intended to reduce code verbosity. Note how +in the example below, it is necessary to write three times 'dependency': + +```python +class A: + ... + + +class B: + def __init__(self, dependency: A): + self.dependency = dependency +``` + +The same classes can be written this way: + +```python +class A: + ... + +class B: + dependency: A +``` + +Rodi would automatically instantiate `B` and populate its `dependency` property +with an instance of `A`. + +```mermaid +graph TD + A[Rodi] --> B[Resolves __init__ methods] + A --> C[Resolves class properties] +``` + +=== "Using constructors" + + ```python {linenums="1", hl_lines="7-8"} + from rodi import Container + + class A: + ... + + class B: + def __init__(self, dependency: A): + self.dependency = dependency + + container = Container() + + container.add_transient(A) + container.add_transient(B) + + example = container.resolve(B) + assert isinstance(example, B) + assert isinstance(example.dependency, A) + ``` + +=== "Using class properties" + + ```python {linenums="1", hl_lines="7"} + from rodi import Container + + class A: + ... + + class B: + dependency: A + + container = Container() + + container.add_transient(A) + container.add_transient(B) + + example = container.resolve(B) + assert isinstance(example, B) + assert isinstance(example.dependency, A) + ``` + +### Container lifetime + +The primary use case of Rodi is to instantiate a single `Container` object, configure it +with all required dependencies at application startup, and maintain it in an immutable +state throughout the application's lifetime. It is anyway possible to work with multiple +containers, and to modify them even after the dependency graph has been built. Modifying +a `Container` after the dependency graph has been built is an anti-pattern and can lead +to unexpected behaviour. More details on this subject are provided in the next page. + +### Sync vs Async + +Rodi is designed for synchronous code. It intentionally does not provide an asynchronous +code API because object constructors should be lightweight and run synchronously. +Supporting asynchronous type resolution would introduce performance overhead due to the +complexity of asynchronous operations, and the extra machinery they require. + +Constructors (`__init__` methods) are typically designed to be lightweight and avoid +CPU intensive blocking operations or performing I/O operations. + +### Type annotations + +Rodi can use both type annotations and naming conventions to build graphs of +dependencies. + +Type annotations is the recommended way to keep the code clean and explicit. + +=== "Using type annotations (recommended)" + + ```python {linenums="1", hl_lines="7"} + from rodi import Container + + class A: + ... + + class B: + def __init__(self, dependency: A): # <-- with type annotation + self.dependency = dependency + + container = Container() + + container.add_transient(A) + container.add_transient(B) + + example = container.resolve(B) + assert isinstance(example, B) + assert isinstance(example.dependency, A) + ``` + +=== "Using naming conventions" + + ```python {linenums="1", hl_lines="7-8 12-13"} + from rodi import Container + + class A: + ... + + class B: + def __init__(self, dependency): # <-- no type annotation + self.dependency = dependency + + container = Container() + + container.add_transient(A) + container.add_alias("dependency", A) # <-- required to resolve + container.add_transient(B) + + example = container.resolve(B) + assert isinstance(example, B) + assert isinstance(example.dependency, A) + ``` + +### Automatic aliases + +Rodi supports automatic aliases. When a type is registered, the container creates a +set of aliases based on the class name. Consider the following example: + +```python {linenums="1", hl_lines="4 8-9"} +from rodi import Container + + +class CatsRepository: ... + + +class B: + def __init__(self, cats_repository): + self.cats_repository = cats_repository + + +container = Container() + +container.add_transient(CatsRepository) +container.add_transient(B) + +example = container.resolve(B) +assert isinstance(example, B) +assert isinstance(example.cats_repository, CatsRepository) +``` + +Aliases are only used when type annotations are missing. They serve solely as a +*fallback* and always refer to a type that can be resolved. + +This design decision is based on the assumption that classes *usually* have names that +are distinct enough to be unambiguously identified, even across namespaces. + +In the example above, the following set of aliases is created for the registered types: + +```python +{ + 'CatsRepository': {}, + 'catsrepository': {}, + 'cats_repository': {}, + 'B': {}, + 'b': {} +} +``` + +/// admonition | Disabling automatic aliases. + type: tip + +Some programmers might dislike the automatic aliasing feature, as it can lead to +unexpected behavior if naming conventions are not followed consistently. To disable this +feature, set the `strict` parameter to `True` when creating the container: + +```python +container = Container(strict=True) +``` +/// + +## Summary + +This page covered the ABCs of Dependency Injection and Rodi. The general concepts +presented here apply to others DI frameworks as well. + +The next page will start diving into Rodi's details, starting with explaining how to +[register types](./registering-types.md). diff --git a/rodi/docs/img/neoteroi-w.svg b/rodi/docs/img/neoteroi-w.svg new file mode 100644 index 0000000..45fd9e7 --- /dev/null +++ b/rodi/docs/img/neoteroi-w.svg @@ -0,0 +1,74 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/rodi/docs/img/neoteroi.ico b/rodi/docs/img/neoteroi.ico new file mode 100644 index 0000000..11cd814 Binary files /dev/null and b/rodi/docs/img/neoteroi.ico differ diff --git a/rodi/docs/index.md b/rodi/docs/index.md new file mode 100644 index 0000000..5cf2672 --- /dev/null +++ b/rodi/docs/index.md @@ -0,0 +1,28 @@ +--- +title: Rodi - Dependency Injection for Python +no_comments: true +--- + +# Rodi is a dependency injection container for Python + +```shell +pip install rodi +``` + +## Rodi offers... + +- A **non-intrusive** implementation of dependency injection, that does not + require modifying the classes it handles (no decorators are needed, no + changes in injected classes). +- A strategy to better organize source code, reduce code repetition, and + improve development experience. +- Simplified dependency management with automatic resolution and injection of + dependencies, by type annotation in constructors or class properties. +- A fast implementation that performs code inspections only when necessary, rather than + at each type resolution. +- A generic code API that can be used with any kind of Python applications. + +## Getting started + +To get started with Rodi, read the [_Getting Started_](./getting-started.md) guide. +To dive straight into instructions on using DI with Rodi, see [_Registering types_](./registering-types.md). diff --git a/rodi/docs/js/fullscreen.js b/rodi/docs/js/fullscreen.js new file mode 100644 index 0000000..7d18c4d --- /dev/null +++ b/rodi/docs/js/fullscreen.js @@ -0,0 +1,26 @@ +document.addEventListener("DOMContentLoaded", function () { + function setFullScreen() { + localStorage.setItem("FULLSCREEN", "Y") + document.documentElement.classList.add("fullscreen"); + } + function exitFullScreen() { + localStorage.setItem("FULLSCREEN", "N") + document.documentElement.classList.remove("fullscreen"); + } + + // Select all radio inputs with the name "__fullscreen" + const fullscreenRadios = document.querySelectorAll('input[name="__fullscreen"]'); + + // Add a change event listener to each radio input + fullscreenRadios.forEach(function (radio) { + radio.addEventListener("change", function () { + if (radio.checked) { + if (radio.id === "__fullscreen") { + setFullScreen(); + } else if (radio.id === "__fullscreen_no") { + exitFullScreen(); + } + } + }); + }); +}); diff --git a/rodi/docs/registering-types.md b/rodi/docs/registering-types.md new file mode 100644 index 0000000..f2e1c6d --- /dev/null +++ b/rodi/docs/registering-types.md @@ -0,0 +1,504 @@ +This page dives into more details, covering the following subjects: + +- [X] Types lifetime. +- [X] Options to register types. +- [X] Using factories. +- [X] Working with simple types. +- [X] Support for collections. +- [X] Working with generic types. +- [X] The `Services` class. +- [X] The `ContainerProtocol`. + +## Types lifetime + +Rodi supports three kinds of lifetimes: + +- **Singleton** lifetime, for types that must be created only once per container. +- **Transient** lifetime, for types that must be created every time they are + requested. +- **Scoped** lifetime, for types that must be created once per resolution scope + (e.g. once per HTTP web request, once per user interaction). + +The next paragraphs describe each type in detail. + +### Transient lifetime + +Transient lifetime is the most common kind for types registered in Rodi. It means that a +new instance of a class will be created every time it is requested. The `Container` +class offers three methods to register types with transient lifetime: + +- **register** to register a _transient_ type by class. +- **add_transient** to register a _transient_ type by class. +- **add_transient_by_factory** to register a _transient_ type by factory function. + +=== "register" + + ```python {linenums="1", hl_lines="8"} + from rodi import Container + + class A: + ... + + container = Container() + + container.register(A) + + a1 = container.resolve(A) + a2 = container.resolve(A) + assert isinstance(a1, A) + assert isinstance(a2, A) + assert a1 is not a2 + ``` + +=== "add_transient" + + ```python {linenums="1", hl_lines="8"} + from rodi import Container + + class A: + ... + + container = Container() + + container.add_transient(A) + + a1 = container.resolve(A) + a2 = container.resolve(A) + assert isinstance(a1, A) + assert isinstance(a2, A) + assert a1 is not a2 + ``` + +=== "add_transient_by_factory" + + ```python {linenums="1", hl_lines="6-7 11"} + from rodi import Container + + class A: + ... + + def a_factory() -> A: + return A() + + container = Container() + + container.add_transient_by_factory(a_factory) + + a1 = container.resolve(A) + a2 = container.resolve(A) + assert isinstance(a1, A) + assert isinstance(a2, A) + assert a1 is not a2 + ``` + +### Singleton lifetime + +The singleton lifetime is used for types that should be instantiated only once per +container's dependency graph. The `Container` class offers three methods to register +types with singleton lifetime: + +- **register** to register a _singleton_ type by class and instance. +- **add_instance** to register a _singleton_ using an instance. +- **add_singleton** to register a _singleton_ type by class. +- **add_singleton_by_factory** to register a _singleton_ type by factory function. + +=== "register" + + ```python {linenums="1", hl_lines="7"} + from rodi import Container + + class A: ... + + container = Container() + + container.register(A, instance=A()) + + a1 = container.resolve(A) + a2 = container.resolve(A) + assert isinstance(a1, A) + assert isinstance(a2, A) + assert a1 is not a2 + ``` + +=== "add_instance" + + ```python {linenums="1", hl_lines="9"} + from rodi import Container + + class Cat: + def __init__(self, name: str): + self.name = name + + container = Container() + + container.add_instance(Cat("Tom")) + + example = container.resolve(Cat) + assert isinstance(example, Cat) + assert example.name == "Tom" + ``` + +=== "add_singleton" + + ```python {linenums="1", hl_lines="8"} + from rodi import Container + + class Cat: + pass + + container = Container() + + container.add_singleton(Cat) + + example = container.resolve(Cat) + assert isinstance(example, Cat) + ``` + +=== "add_singleton_by_factory" + + ```python {linenums="1", hl_lines="9-10 12"} + from rodi import Container + + class Cat: + def __init__(self, name: str): + self.name = name + + container = Container() + + def cat_factory() -> Cat: + return Cat("Tom") + + container.add_singleton_by_factory(Cat) + + example = container.resolve(Cat) + assert isinstance(example, Cat) + assert example.name == "Tom" + ``` + +/// admonition | Container lifecycle. + type: danger + +If you modify the `Container` after the dependency tree has been created, for example +registering a new type after any type has been resolved, all created singletons are +discarded and will be recreated when requested again. Modifying the `Container` during +the lifetime of the application is an anti-pattern, and should be avoided. It also +forces the container to repeat code inspections, causing a performance fee. + +To avoid exposing the mutable `container`, use the `container.build_provider()` +method, which returns an instance of `Services` that can only be used to +resolve types, without modifying the tree graph. The `Services` class still +offers a `set` method, which can only be used to add new singletons to the +set of types that can be instantiated. +/// + +### Scoped lifetime + +The scoped lifetime is used for types that should be instantiated only once per +container's resolution call. The `Container` class offers two methods to register types +with scoped lifetime: + +- **add_scoped** to register a _scoped_ type by class. +- **add_scoped_by_factory** to register a _scoped_ type by factory function. + +=== "add_scoped" + + ```python {linenums="1", hl_lines="7 10 15 19 23 25 29 31"} + from rodi import Container + + class A: + ... + + class B: + context: A + + class C: + context: A + dependency: B + + container = Container() + + container.add_scoped(A) + container.add_scoped(B) + container.add_scoped(C) + + c1 = container.resolve(C) # A is created only once for both B and C + assert isinstance(c1, C) + assert isinstance(c1.dependency, B) + assert isinstance(c1.context, A) + assert c1.context is c1.dependency.context + + c2 = container.resolve(C) + assert isinstance(c2, C) + assert isinstance(c2.dependency, B) + assert isinstance(c2.context, A) + assert c2.context is c2.dependency.context + + assert c1.context is not c2.context + ``` + +=== "add_scoped_by_factory" + + ```python {linenums="1", hl_lines="16-17 22"} + from rodi import Container + + + class A: ... + + + class B: + context: A + + + class C: + context: A + dependency: B + + + def a_factory() -> A: + return A() + + + container = Container() + + container.add_scoped_by_factory(a_factory) + container.add_scoped(B) + container.add_scoped(C) + + c1 = container.resolve(C) # A is created only once for both B and C + assert isinstance(c1, C) + assert isinstance(c1.dependency, B) + assert isinstance(c1.context, A) + assert c1.context is c1.dependency.context + + c2 = container.resolve(C) + assert isinstance(c2, C) + assert isinstance(c2.dependency, B) + assert isinstance(c2.context, A) + assert c2.context is c2.dependency.context + + assert c1.context is not c2.context + ``` + + +## Using factories + +**add_transient_by_factory**, **add_singleton_by_factory**, and **add_scoped_by_factory** +accept a function that returns an instance of the type to register. + +Valid function signatures include: + +- `def factory():` +- `def factory(context: rodi.ActivationScope):` +- `def factory(context: rodi.ActivationScope, activating_type: type):` + +The context is the current activation scope, and grants access to the set of +scoped services and to the `ServiceProvider` object under construction. The +`activating_type` is the type that is being activated and required resolving +the service. This can be useful in some scenarios, when the returned object +must vary depending on the type that required it. + +```python {linenums="1", hl_lines="15-16"} +from rodi import ActivationScope, Container + + +class A: ... + +class B: + friend: A + +class C: ... + +container = Container() + + +def a_factory(context, activating_type) -> A: + assert isinstance(context, ActivationScope) + assert activating_type is B + + # You can obtain other types using `context.provider.get` + # (if they can be resolved) + c = context.provider.get(C) + assert isinstance(c, C) + + return A() + + +container.add_transient_by_factory(a_factory) +container.add_transient(B) +container.add_transient(C) + +b = container.resolve(B) +assert isinstance(b.friend, A) +``` + +## Working with simple types + +**Dependency Injection** loves custom types. Consider the following example: + +```python +class Example: + def __init__(self, api_key: str): + if not api_key: + raise ValueError("API key is required") + self.api_key = settings.api_key +``` + +The `Example` class depends on a `str`. We could register a `str` singleton in +our DI container, but it wouldn't make sense. Some other class might require a +`str` dependency, and we would be out of options to resolve them. All types +that require a simple type passed to their constructor are best configured +using a _factory_ function. + +```python +def example_factory() -> Example: + return Example(os.environ.get("API_KEY")) +``` + +In many cases, it is advisable to define custom types to group settings +consisting of simple types into dedicated classes. + +For example: + +```python {linenums="1", hl_lines="2-3 7"} +@dataclass +class SendGridClientSettings: + api_key: str + + +class SendGridClient(EmailHandler): + settings: SendGridClientSettings + http_client: httpx.AsyncClient +``` + +This approach has the following benefits: + +- A factory can be used to obtain the settings class. +- The more complex type can be resolved using less verbose methods that inspect + its constructor or class properties. + +## Support for collections + +Rodi supports registering and resolving collections. + +```python {linenums="1", hl_lines="11-12"} +from rodi import Container + + +class A: ... + + +class B: + friends: list[A] + + +def friends_factory() -> list[A]: + return [A(), A()] + + +container = Container() + +container.add_transient_by_factory(friends_factory) +container.add_transient(B) + +b = container.resolve(B) +print(b.friends) +assert isinstance(b.friends, list) +assert isinstance(b.friends[0], A) +assert isinstance(b.friends[1], A) +``` + +Other containers such as `dict`, `set`, `Iterable`, `Mapping`, `Sequence`, +`Tuple` are also supported. + +## The Services class + +The `Container` class in Rodi can be used to register and resolve types, and it +is mutable (new types can be registered at any time). This design decision was +driven by the desire to keep the code API as simple as possible, and to enable +the possibility to replace the Rodi's container with alternative +implementations of dependency injection. + +Although the container is mutable, it is generally recommended to use it in the +following way: + +- Register all types in the container during application startup. +- Resolve types at runtime without registering new ones. + +It can be undesirable to expose the mutable `Container` to the application +code, as it can lead to unexpected behavior. For this reason, the `Container` +class provides a method called `build_provider`, which returns a read-only +interface that can be used to resolve types, but not to register new ones +(with the exception of the `set` method, which allows adding new singletons +without altering the existing dependency tree). + +```python +from rodi import Container + + +class A: ... + + +container = Container() + +container.add_transient(A) + +provider = container.build_provider() + +a1 = provider.get(A) +a2 = provider.get(A) +assert isinstance(a1, A) +assert isinstance(a2, A) +assert a1 is not a2 +``` + +### The ContainerProtocol + +Rodi defines a protocol for the `Container` class, named `ContainerProtocol`. +This protocol defines a generic interface of the container, which includes +methods for registering and resolving types, as well as checking if a type is +configured in the container. + +The purpose of this protocol is to support replacing Rodi with alternative +implementations of dependency injection in code that requires basic container +functionality. The protocol is defined as follows: + +```python +class ContainerProtocol(Protocol): + """ + Generic interface of DI Container that can register and resolve services, + and tell if a type is configured. + """ + + def register(self, obj_type: Union[Type, str], *args, **kwargs): + """Registers a type in the container, with optional arguments.""" + + def resolve(self, obj_type: Union[Type[T], str], *args, **kwargs) -> T: + """Activates an instance of the given type, with optional arguments.""" + + def __contains__(self, item) -> bool: + """ + Returns a value indicating whether a given type is configured in this + container. + """ +``` + +Since some features, like _Service Lifetime_ are specific to Rodi (some alternative +implementations only support _transient_ and _singleton_ lifetimes), the protocol does +not define methods for registering types with different lifetimes. The protocol only +defines unopinionated methods to `register` and `resolve` types, and to check if a type +is configured. + +/// admonition | Interoperability. + type: tip + +If you author code that relies on a Dependency Injection container and you want to +support different implementations, you would need to decide on a common interface, or +[_Protocol_](https://peps.python.org/pep-0544/), required by your code. The +`ContainerProtocol` interface was originally thought for this purpose. +/// + +## Next steps + +All examples on this page show how to register and resolve _concrete_ classes. +The next page describes how to apply the [_Dependency Inversion Principle_](./dependency-inversion.md), +how to work with _abstract_ classes, protocols, and generics. diff --git a/rodi/docs/union-types.md b/rodi/docs/union-types.md new file mode 100644 index 0000000..dc70a41 --- /dev/null +++ b/rodi/docs/union-types.md @@ -0,0 +1,106 @@ +This page describes support for _Union_ types in Rodi. + +- [X] Optional dependencies. +- [X] Union types dependencies. + +## Optional dependencies + +It is uncommon for types resolved with dependency injection to have optional +dependencies, however this scenario is supported by Rodi. + +```python {linenums="1", hl_lines="8 12 15"} +from rodi import Container + + +class A: ... + + +class B: + dependency: A | None + + +container = Container() +container.register(A | None, A) +container.register(B) + +b = container.resolve(B) +assert isinstance(b.dependency, A) +``` + +/// admonition | Optional types keys. + type: warning + +Beware that if you specify a _T_ dependency as _optional_, the _key_ type used to +resolve the dependency becomes the _T | None_ and it is not just _T_. +/// + +A factory function can be used to define logic that determines how the +dependency must be resolved: + +```python {linenums="1", hl_lines="8 12 15"} +from rodi import Container + + +class A: ... + + +class B: + dependency: A | None + + +def a_factory() -> A | None: + # TODO: implement logic that determines what to return + return None + + +container = Container() +container.add_transient_by_factory(a_factory) +container.register(B) + +b = container.resolve(B) +assert b.dependency is None +``` + +## Union dependencies + +Union types are also supported: + +```python {linenums="1", hl_lines="11 14 20 23-24"} +from rodi import Container + + +class A: ... + + +class B: ... + + +class C: + dependency: A | B + + +def ab_factory() -> A | B: + # TODO: implement logic that determines what to return + return A() + + +container = Container() +container.add_transient_by_factory(ab_factory) +container.register(C) + +c = container.resolve(C) +assert isinstance(c.dependency, A) +``` + +/// admonition | Union types keys. + type: warning + +Beware that if you specify a union dependency such as _T | U_ the _key_ type +used to resolve the dependency is _T | U_. Trying to use _T_ or _U_ +singularly causes a _`CannotResolveTypeException`_. +/// + + +--- + +The next page provides an overview of [errors](./errors.md) raised by Rodi. diff --git a/rodi/mkdocs.yml b/rodi/mkdocs.yml new file mode 100644 index 0000000..2d8bf35 --- /dev/null +++ b/rodi/mkdocs.yml @@ -0,0 +1,83 @@ +site_name: Rodi +site_author: Roberto Prevato +site_description: Rodi, an implementation of Dependency Injection container for Python +site_url: https://www.neoteroi.dev/rodi/ +repo_name: Neoteroi/Rodi +repo_url: https://github.com/Neoteroi/rodi +edit_uri: "" + +nav: + - Overview: index.md + - Getting started: getting-started.md + - Registering types: registering-types.md + - Dependecy inversion: dependency-inversion.md + - Working with async: async.md + - Context managers: context-managers.md + - Union types: union-types.md + - Errors: errors.md + - About Rodi: about.md + - Neoteroi docs home: "/" + +theme: + features: + - navigation.footer + - content.code.copy + - content.action.view + palette: + - scheme: slate + toggle: + icon: material/toggle-switch + name: Switch to light mode + - scheme: default + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + name: "material" + custom_dir: overrides/ + # highlightjs: true # ? + favicon: img/neoteroi.ico + logo: img/neoteroi-w.svg + icon: + repo: fontawesome/brands/github + +extra: + header_bg_color: "teal" # "#51003c" + +extra_css: + - css/neoteroi.css + - css/extra.css?v=20221120 + +extra_javascript: + - js/fullscreen.js + +plugins: + - search + - neoteroi.contribs + +markdown_extensions: + # - markdown.extensions.codehilite: + # linenums: true + # guess_lang: false + - pymdownx.highlight: + use_pygments: true + guess_lang: false + anchor_linenums: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tabbed: + alternate_style: true + - toc: + permalink: true + - pymdownx.blocks.admonition + - pymdownx.blocks.details + - neoteroi.timeline + - neoteroi.cards + - neoteroi.projects + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg diff --git a/rodi/overrides/main.html b/rodi/overrides/main.html new file mode 100644 index 0000000..f51ad86 --- /dev/null +++ b/rodi/overrides/main.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block extrahead %} + {% set title = config.site_name %} + {% if page and page.title and not page.is_homepage %} + {% set title = config.site_name ~ " - " ~ page.title | striptags %} + {% endif %} + {% set image = config.site_url ~ 'img/banner.png' %} + + + + + + + + + + + + + + + +{% endblock %} +{% block content %} + {{ super() }} +{% endblock %} +{% block analytics %} + + +{% endblock %} diff --git a/rodi/overrides/partials/comments.html b/rodi/overrides/partials/comments.html new file mode 100644 index 0000000..c62c877 --- /dev/null +++ b/rodi/overrides/partials/comments.html @@ -0,0 +1,49 @@ +{% if not page.meta.no_comments %} + + + + + +{% endif %} diff --git a/rodi/overrides/partials/content.html b/rodi/overrides/partials/content.html new file mode 100644 index 0000000..63fa92f --- /dev/null +++ b/rodi/overrides/partials/content.html @@ -0,0 +1,16 @@ +{% if "tags" in config.plugins %} + {% include "partials/tags.html" %} +{% endif %} +{% include "partials/actions.html" %} +{% if not "\x3ch1" in page.content %} +

{{ page.title | d(config.site_name, true)}}

+{% endif %} +{{ page.content }} +{% if page.meta and ( + page.meta.git_revision_date_localized or + page.meta.revision_date +) %} + {% include "partials/source-file.html" %} +{% endif %} +{% include "partials/feedback.html" %} +{% include "partials/comments.html" %} diff --git a/rodi/overrides/partials/header.html b/rodi/overrides/partials/header.html new file mode 100644 index 0000000..4842e4c --- /dev/null +++ b/rodi/overrides/partials/header.html @@ -0,0 +1,76 @@ +{#- + This file was automatically generated - do not edit +-#} +{% set class = "md-header" %} +{% if "navigation.tabs.sticky" in features %} + {% set class = class ~ " md-header--shadow md-header--lifted" %} +{% elif "navigation.tabs" not in features %} + {% set class = class ~ " md-header--shadow" %} +{% endif %} +
+ + {% if "navigation.tabs.sticky" in features %} + {% if "navigation.tabs" in features %} + {% include "partials/tabs.html" %} + {% endif %} + {% endif %} +