Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,42 @@
- Integrations with `FastAPI`, `FastStream`, `LiteStar` and `Typer`
- Pytest integration (`modern-di-pytest`) — turns any DI dependency into a pytest fixture

## Install

```bash
uv add modern-di # or: pip install modern-di
```

## Quick Start

```python
import dataclasses
from modern_di import Container, Group, Scope, providers


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Settings:
database_url: str = "postgresql+asyncpg://localhost/app"


@dataclasses.dataclass(kw_only=True, slots=True)
class UserRepository:
settings: Settings # auto-injected by type


class Dependencies(Group):
settings = providers.Factory(scope=Scope.APP, creator=Settings)
user_repository = providers.Factory(scope=Scope.REQUEST, creator=UserRepository)


with Container(groups=[Dependencies], validate=True) as container:
with container.build_child_container(scope=Scope.REQUEST) as request:
repo = request.resolve(UserRepository)
print(repo.settings.database_url)
```

See the [documentation](https://modern-di.modern-python.org) for scopes, lifecycles, finalizers, and framework integrations.

Usage examples:

- with LiteStar - [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template)
Expand Down
5 changes: 5 additions & 0 deletions architecture/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ time each parameter is matched against the container's `providers_registry` by i
If `bound_type` is supplied explicitly it overrides the inferred return type (useful when the creator returns a
protocol or base class narrower than the concrete type).

A creator parameter that cannot be resolved by type raises `UnsupportedCreatorParameterError` at **declaration
time**. This fires when a parameter is a parameterized generic (e.g. `list[int]`, `dict[str, Foo]`) or
positional-only, has no default, and is not supplied via `kwargs`. The three escape hatches are: pass the value
via `kwargs={...}`, give the parameter a default, or set `skip_creator_parsing=True`.

### Recursive resolution

When a `Factory` is resolved, `_compile_kwargs` iterates the parsed parameter map. For each parameter it looks up
Expand Down
5 changes: 3 additions & 2 deletions architecture/scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,13 @@ rules apply. Passing a non-`IntEnum` value raises `InvalidScopeTypeError`.
## Worked example

```python
from modern_di import Container, Scope
from modern_di import Container, Group, Scope
from modern_di import providers

# DatabasePool and UserFromRequest are your own classes.
class AppGroup(Group):
# Resolved once and cached for the lifetime of the app container.
db_pool = providers.Factory(scope=Scope.APP, creator=DatabasePool, cache_settings=CacheSettings())
db_pool = providers.Factory(scope=Scope.APP, creator=DatabasePool, cache_settings=providers.CacheSettings())

# Resolved once per request container.
current_user = providers.Factory(scope=Scope.REQUEST, creator=UserFromRequest)
Expand Down
9 changes: 8 additions & 1 deletion docs/dev/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This is an open source project, and we are open to new contributors.
1. Make sure that you have [uv](https://docs.astral.sh/uv/) and [just](https://just.systems/) installed.
2. Clone project:
```
git@github.com:modern-python/modern-di.git
git clone git@github.com:modern-python/modern-di.git # or: git clone https://github.com/modern-python/modern-di.git
cd modern-di
```
3. Install dependencies by running `just install`
Expand All @@ -17,3 +17,10 @@ Run all checks by command `just lint`

## Running tests
Run all tests by command `just test`

## Submitting changes
1. Fork the repo and branch off `main`.
2. Make your change with tests; keep **100% line coverage** (CI runs `just test-ci` with `--cov-fail-under=100`).
3. Run `just lint` and `just test` locally before pushing (CI runs the non-fixing variants `just lint-ci` / `just test-ci`).
4. For non-trivial changes, see the [planning convention](https://github.com/modern-python/modern-di/blob/main/planning/README.md).
5. Open a pull request upstream.
28 changes: 15 additions & 13 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,23 @@ The integration package builds the per-request child container automatically and
from modern_di import Container, Scope


async def main() -> None:
# Pass validate=True to detect cycles and scope-chain errors at startup
async with Container(groups=[Dependencies], validate=True) as container:
# APP-scoped providers resolve straight from the container
settings = container.resolve(Settings)

# REQUEST-scoped providers need a REQUEST child container
async with container.build_child_container(scope=Scope.REQUEST) as request:
repo = request.resolve(UserRepository)
user = repo.find(42)

# Request-scope finalizers ran on `async with` exit
# App-scope finalizers ran on the outer `async with` exit
# Pass validate=True to detect cycles and scope-chain errors at startup
with Container(groups=[Dependencies], validate=True) as container:
# APP-scoped providers resolve straight from the container
settings = container.resolve(Settings)

# REQUEST-scoped providers need a REQUEST child container
with container.build_child_container(scope=Scope.REQUEST) as request:
repo = request.resolve(UserRepository)
user = repo.find(42)

# Request-scope finalizers ran on `with` exit
# App-scope finalizers ran on the outer `with` exit
```

Resolution is always synchronous. Use `async with` (on both the container and the child) only when a
provider registers an **async** finalizer — see [Lifecycle](providers/lifecycle.md).

## Where to next

- [Scopes](providers/scopes.md) — the APP → REQUEST lifetime model in one page.
Expand Down
4 changes: 2 additions & 2 deletions docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## How to use

1. Install `modern-di-fastapi`:
### 1. Install `modern-di-fastapi`

=== "uv"

Expand All @@ -24,7 +24,7 @@
poetry add modern-di-fastapi
```

2. Apply this code example to your application:
### 2. Apply to your application
```python
import datetime
import contextlib
Expand Down
4 changes: 2 additions & 2 deletions docs/integrations/faststream.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## How to use

1. Install `modern-di-faststream`:
### 1. Install `modern-di-faststream`

=== "uv"

Expand All @@ -22,7 +22,7 @@
poetry add modern-di-faststream
```

2. Apply this code example to your application:
### 2. Apply to your application

```python
import dataclasses
Expand Down
26 changes: 20 additions & 6 deletions docs/integrations/litestar.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## How to use

1. Install `modern-di-litestar`:
### 1. Install `modern-di-litestar`

=== "uv"

Expand All @@ -24,7 +24,7 @@
poetry add modern-di-litestar
```

2. Apply this code example to your application:
### 2. Apply to your application
```python
import datetime
import typing
Expand Down Expand Up @@ -109,28 +109,42 @@ But when websockets are used, `SESSION` scope is used as well:
`REQUEST` scope must be entered manually:

```python
import dataclasses
import litestar
from modern_di import Container, Scope
from modern_di import Container, Group, Scope, providers
import modern_di_litestar


@dataclasses.dataclass
class MyService:
async def handle(self, data: str) -> None: ...


class Dependencies(Group):
my_service = providers.Factory(scope=Scope.REQUEST, creator=MyService)


ALL_GROUPS = [Dependencies]

app = litestar.Litestar(plugins=[modern_di_litestar.ModernDIPlugin(Container(groups=ALL_GROUPS))])


@litestar.websocket_listener("/ws")
async def websocket_handler(
data: str,
di_container: Container
di_container: Container, # auto-resolved — the plugin registers a "di_container" dependency
) -> None:
# For a websocket, di_container is the SESSION-scoped child; enter REQUEST scope here
async with di_container.build_child_container(scope=Scope.REQUEST) as request_container:
# REQUEST scope is entered here
# You can resolve dependencies here
service = request_container.resolve(MyService)
await service.handle(data)


app.register(websocket_handler)
```

`di_container` is injected by name — the plugin registers it as a Litestar dependency, so you don't need a `FromDI` marker for the container itself.

## Framework Context Objects

Framework-specific context objects like `litestar.Request` and `litestar.WebSocket` are automatically made available by the integration.
Expand Down
17 changes: 11 additions & 6 deletions docs/integrations/pytest.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ one or more `Group` subclasses.

## How to use

1. Install `modern-di-pytest`:
### 1. Install

=== "uv"

Expand All @@ -27,8 +27,10 @@ one or more `Group` subclasses.
poetry add --group dev modern-di-pytest
```

2. Define a `di_container` fixture at the highest pytest scope you want.
The plugin never builds the container — you own it:
### 2. Define a `di_container` fixture

Define it at the highest pytest scope you want. The plugin never builds the
container — you own it:

```python
import typing
Expand All @@ -45,8 +47,9 @@ def di_container() -> typing.Iterator[modern_di.Container]:
yield container
```

3. Materialize dependencies as fixtures, either in bulk via `expose` or
one-by-one via `modern_di_fixture`:
### 3. Materialize dependencies as fixtures

Either in bulk via `expose` or one-by-one via `modern_di_fixture`:

```python
from modern_di_pytest import expose, modern_di_fixture
Expand All @@ -65,7 +68,9 @@ expose(Dependencies, Auth, Billing)
email_client = modern_di_fixture(EmailClient)
```

4. Tests receive resolved dependencies by name:
### 4. Use the fixtures in tests

Tests receive resolved dependencies by name:

```python
from app.services import EmailClient, UserService
Expand Down
4 changes: 2 additions & 2 deletions docs/integrations/typer.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## How to use

1. Install `modern-di-typer`:
### 1. Install `modern-di-typer`

=== "uv"

Expand All @@ -22,7 +22,7 @@
poetry add modern-di-typer
```

2. Apply this code example to your application:
### 2. Apply to your application

```python
import dataclasses
Expand Down
7 changes: 7 additions & 0 deletions docs/migration/from-that-depends.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,13 @@ async def lifespan(app: fastapi.FastAPI) -> AsyncIterator[None]:
app = fastapi.FastAPI(lifespan=lifespan)
```

> This hand-written `lifespan` manages the container yourself (`async with container` runs
> `close_async` on exit). If you use the [`modern-di-fastapi` integration](../integrations/fastapi.md),
> `setup_di(app, container)` already appends a lifespan that closes the container — and it **merges**
> with any `lifespan=` you pass to `FastAPI(...)`. So when combining the integration with a custom
> async resource, keep the `aiohttp.ClientSession` setup in your `lifespan` but **drop the
> `async with container` wrapper** to avoid closing the container twice.

Downstream factories declare `client: aiohttp.ClientSession` as a parameter and get the live instance via type-based resolution. Use this pattern for `aiohttp.ClientSession`, `asyncpg.create_pool`, or any resource whose constructor genuinely requires `await` or a running event loop. Resources that *look* async but construct synchronously (`redis.asyncio.Redis.from_url`, `sqlalchemy.ext.asyncio.create_async_engine`, `httpx.AsyncClient`) are better expressed as the previous case — sync creator with an async finalizer.

### Per-request async construction
Expand Down
27 changes: 24 additions & 3 deletions docs/migration/to-2.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,10 @@ class AuthService:
token: str
expiry: int

# Define providers for UserService and AuthService first
user_service_provider = providers.Factory(creator=UserService)
auth_service_provider = providers.Factory(creator=AuthService)
# Define providers for UserService and AuthService first.
# Primitive fields (str/int) have no provider — supply them via kwargs.
user_service_provider = providers.Factory(creator=UserService, kwargs={"name": "admin", "age": 30})
auth_service_provider = providers.Factory(creator=AuthService, kwargs={"token": "secret", "expiry": 3600})

# For dictionaries
def create_services_dict(user_service: UserService, auth_service: AuthService) -> dict[str, object]:
Expand Down Expand Up @@ -244,6 +245,26 @@ instance = container.resolve(SomeType)
!!! note "Async finalizers are still supported"
Only *resolution* became sync-only in 2.x. Async *finalizers* (cleanup functions) are still fully supported via `CacheSettings(finalizer=async_cleanup_fn)` and `await container.close_async()`. The distinction: you cannot `await` during dependency resolution, but you can use async functions to clean up resources when a container is closed.

### 7. Migrating `.cast`

In 1.x, `.cast` wired one provider into another's dependency, e.g.
`UserService(db_engine=database_engine.cast)`. There is no `.cast` in 2.x — wiring is by type.
Map each 1.x usage:

| 1.x | 2.x |
|---|---|
| `dep=other_provider.cast` (a provider dependency) | Drop the argument — annotate the creator parameter with the dependency's type; it's resolved by type automatically. |
| `value=settings.host` (a static/literal value) | Pass it in `kwargs={"value": ...}`. |
| a request/context value | Register a `ContextProvider` for that type (see [Context](../providers/context.md)). |

```python
# 1.x
service = providers.Factory(MyService, db_engine=database_engine.cast)

# 2.x — MyService.__init__(self, db_engine: DBEngine); db_engine resolved by type
service = providers.Factory(scope=Scope.APP, creator=MyService)
```

## Migration Steps

1. **Update Dependencies**: Ensure all modern-di packages are updated to 2.x versions
Expand Down
42 changes: 42 additions & 0 deletions docs/providers/advanced-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Advanced / low-level API

Lower-level public surface for library authors and advanced use-cases.

## `Container` attributes and methods

- **`find_container(scope)`** — walks `scope_map` and returns the container registered at
`scope`; raises `ScopeNotInitializedError` or `ScopeSkippedError` if the scope is absent.
- **`parent_container`** — constructor kwarg and slot; the direct parent of a child container,
or `None` for a root. Passing a `scope ≤ parent.scope` raises `InvalidChildScopeError`.
- **`scope_map`** — `dict[IntEnum, Container]` mapping every scope in the chain to its
container; built at construction time and inherited (plus the new scope) by each child.
- **`lock`** — a `threading.RLock` instance, or `None` when the container was created with
`use_lock=False`. The lock gates singleton creation inside `Factory.resolve`.

## `Group.get_providers()`

`Group.get_providers()` is a classmethod that traverses the MRO (excluding `Group` and
`object`) and collects every class attribute that is an `AbstractProvider` instance, respecting
MRO override order (subclass attribute shadows parent attribute of the same name). Use it to
inspect or iterate all providers declared on a group hierarchy.

## Subclassing `AbstractProvider`

To implement a custom provider, subclass `AbstractProvider` and implement:

- **`resolve(container)`** *(required)* — returns the resolved instance.
- **`get_dependencies(container)`** *(optional)* — returns a `dict[str, AbstractProvider]`
mapping parameter names to their providers; used by `Container.validate()` for graph
traversal.
- **`iter_validation_issues(container)`** *(optional)* — yields `Exception` instances for
validation-time problems found in this provider; default yields nothing.
- **`effective_scope(container)`** *(optional override)* — override this method to report the
scope of whatever the provider ultimately resolves to. A transparent or redirect provider (like
`Alias`) should override it to follow the source chain so that `Container.validate()` checks
callers against the real target scope rather than any nominal scope on the wrapper itself.

## `CacheSettings.is_async_finalizer`

`CacheSettings.is_async_finalizer` is a computed bool field set at construction time via
`inspect.iscoroutinefunction(finalizer)`. `Factory.resolve` and the cache registry use it to
decide whether to `await` the finalizer during `close_async()` or treat it as sync.
Loading