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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

`modern-di` is a python dependency injection framework which supports the following:

- Automatic dependencies graph based on type annotations
- Automatic dependency graph based on type annotations
- Also, explicit dependencies are allowed where needed
- Scopes and context management
- Python 3.10+ support
Expand Down
14 changes: 12 additions & 2 deletions architecture/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ providers — resolution, scoping, overriding — flows through a `Container`.
## Creating a root container

```python
from modern_di import Container, Scope
from modern_di.group import Group
from modern_di import Container, Scope, Group

class MyGroup(Group):
...
Expand Down Expand Up @@ -74,6 +73,17 @@ under `Container` rather than inferred from a type annotation).

## Lifecycle: close and reopen

The idiomatic happy path is the `with` statement: it builds the container, runs finalizers in
LIFO order on the way out, and guarantees close even if the body raises. Most code never needs to
call `close_sync()`/`close_async()` directly.

```python
with Container(scope=Scope.APP, groups=[MyGroup]) as container:
... # resolve providers here; finalizers run on exit
```

The rest of this section documents what that close performs and how reopen works.

### Closing

`close_sync()` and `close_async()` both do two things in order:
Expand Down
11 changes: 8 additions & 3 deletions architecture/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ respecting inheritance order and de-duplicating by name.

## `Factory` — the universal provider

`Factory` is the main building block. Every provider that calls a constructor or factory function is a `Factory`.
`Factory` is the main building block. Every provider that calls a creator callable (a constructor or factory function passed as `creator=`) is a `Factory`.

### Signature

Expand Down Expand Up @@ -64,9 +64,10 @@ a matching provider by type in the registry and recurses into `container.resolve
Resolution errors are annotated with a breadcrumb describing the current factory, so the full chain appears in the
exception.

### Static kwargs — `kwargs={}`
### Static kwargs

Pass `kwargs` to supply static (non-DI-resolved) arguments that bypass type-based resolution. These are merged
Pass `kwargs` to supply static (non-DI-resolved) arguments that bypass type-based resolution — for
example `kwargs={"timeout": 30}` to pin a creator's `timeout` parameter. These are merged
last, overriding any provider-resolved value for the same key. Supplying a key that does not appear in the
creator's signature (and whose creator has no `**kwargs`) raises `UnknownFactoryKwargError` at declaration time.

Expand Down Expand Up @@ -117,6 +118,8 @@ value was supplied (the key is absent), `resolve` returns `None`. `Factory._comp
absent-context case explicitly: if the dependent parameter has a default or is nullable it is silently satisfied;
otherwise an `ArgumentResolutionError` is raised.

`ContextProvider` also accepts an optional `bound_type` that overrides the inferred bound type.

---

## `Alias` — re-exporting a type under a different name
Expand All @@ -131,6 +134,8 @@ This lets code that depends on `DatabaseProtocol` receive the `ConcreteDatabase`
needing a separate `Factory` for the protocol. `Alias.resolve` calls `container.resolve_provider(source_provider)`,
so caching and lifecycle are fully governed by the source.

`Alias` also accepts an optional `bound_type` that overrides the inferred bound type.

`Alias.effective_scope` follows alias chains transitively to the terminal non-alias provider and returns that
provider's scope. This is what `Container.validate()` and scope-error reporting use — the alias's own `scope`
attribute is only a stored default.
Expand Down
8 changes: 4 additions & 4 deletions architecture/testing-and-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ cached instance. After `reset_override`, the original cache entry (if any) is st
Define a `Group` subclass with providers as class-level attributes and pass it to `Container`:

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

class MyGroup(Group):
service = providers.Factory(scope=Scope.APP, creator=MyService)
repo = providers.Factory(scope=Scope.APP, creator=MyRepo)
service = providers.Factory(scope=Scope.APP, creator=MyService)
repo = providers.Factory(scope=Scope.APP, creator=MyRepo)
request_scoped_service = providers.Factory(scope=Scope.REQUEST, creator=RequestScopedService)

container = Container(scope=Scope.APP, groups=[MyGroup])
```
Expand Down
4 changes: 3 additions & 1 deletion docs/dev/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ cd modern-di
Run all checks by command `just lint`

## Running tests
Run all tests by command `just test`
Run all tests by command `just test`. Run a subset with `just test <PATH> -k <NAME>`.

CI runs the coverage-enforcing recipe `just test-ci` along with `just lint-ci`.

## Submitting changes
1. Fork the repo and branch off `main`.
Expand Down
14 changes: 10 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Welcome to the `modern-di` documentation!

`modern-di` is a Python dependency injection framework which supports the following:

- Automatic dependencies graph based on type annotations
- Automatic dependency graph based on type annotations
- Also, explicit dependencies are allowed where needed
- Scopes and context management
- Python 3.10+ support
Expand Down Expand Up @@ -86,7 +86,11 @@ class Dependencies(Group):
)
```

## 4.1. Integrate with your framework
## 4. Wire it up

Pick **one** of the two mutually-exclusive options below.

### Option A — integrate with your framework

Pick the integration you need:

Expand All @@ -98,7 +102,7 @@ Pick the integration you need:

The integration package builds the per-request child container automatically and closes the APP container at shutdown.

## 4.2. Or use `modern-di` directly
### Option B — use modern-di directly

```python
from modern_di import Container, Scope
Expand All @@ -114,7 +118,7 @@ with Container(groups=[Dependencies], validate=True) as container:
repo = request.resolve(UserRepository)
user = repo.find(42)

# Request-scope finalizers ran on `with` exit
# Request-scope finalizers (teardown hooks such as closing a DB connection) ran on `with` exit
# App-scope finalizers ran on the outer `with` exit
```

Expand All @@ -123,6 +127,8 @@ provider registers an **async** finalizer — see [Lifecycle](providers/lifecycl

## Where to next

- [Resolving](introduction/resolving.md) — how type-based auto-injection works.
- [Factories](providers/factories.md) — the provider you just used.
- [Scopes](providers/scopes.md) — the APP → REQUEST lifetime model in one page.
- [Lifecycle](providers/lifecycle.md) — finalizers, `close_async()`, validation.
- [Recipes](recipes/sqlalchemy.md) — async SQLAlchemy, lifespan-managed resources, testing with overrides.
12 changes: 11 additions & 1 deletion docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class AppGroup(Group):
ALL_GROUPS = [AppGroup]

# Setup DI with your groups
modern_di_fastapi.setup_di(app, Container(groups=ALL_GROUPS))
modern_di_fastapi.setup_di(app, Container(groups=ALL_GROUPS, validate=True))


@app.get("/")
Expand Down Expand Up @@ -166,3 +166,13 @@ class AppGroup(Group):
kwargs={"request": modern_di_fastapi.fastapi_request_provider}
)
```

## API

| Symbol | Description |
|---|---|
| `setup_di(app, container)` | Registers the container on the FastAPI app and appends a lifespan that closes it on shutdown (merges with any existing `lifespan=`); returns the container. |
| `FromDI(dependency, *, use_cache=True)` | A `fastapi.Depends` wrapper that resolves a provider (or type) from the per-request child container. |
| `build_di_container(connection)` | A `fastapi.Depends` callable that yields the per-request child container — REQUEST scope for an HTTP request, SESSION scope for a WebSocket. |
| `fastapi_request_provider` | `ContextProvider` for `fastapi.Request` (REQUEST scope), auto-registered. |
| `fastapi_websocket_provider` | `ContextProvider` for `fastapi.WebSocket` (SESSION scope), auto-registered. |
1 change: 0 additions & 1 deletion docs/integrations/litestar.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
### 2. Apply to your application
```python
import datetime
import typing

from litestar import Litestar, get
import modern_di_litestar
Expand Down
4 changes: 4 additions & 0 deletions docs/integrations/pytest.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ from app.services import EmailClient
# duplicate names across groups raise ValueError. Non-Provider attributes
# are skipped.
expose(Dependencies, Auth, Billing)
# e.g. user_service is the attribute name on the Dependencies group,
# so it becomes the user_service fixture used in the tests below.

# Manual: a single type or Provider as a named fixture.
email_client = modern_di_fixture(EmailClient)
Expand Down Expand Up @@ -159,3 +161,5 @@ def mock_user_repo(di_container: modern_di.Container) -> typing.Iterator[None]:
yield
di_container.reset_override(Dependencies.user_repo)
```

For deeper patterns (transactional DB sessions, resetting all overrides) see the [testing-with-overrides recipe](../recipes/testing-overrides.md).
9 changes: 7 additions & 2 deletions docs/integrations/typer.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import dataclasses
import typing

import modern_di
import modern_di_typer
import typer
from modern_di import Container, Group, Scope, providers
Expand Down Expand Up @@ -88,15 +87,21 @@ To resolve `Scope.ACTION` dependencies, inject `modern_di.Container` — `@injec
`REQUEST`-scoped container it creates per invocation. Call `build_child_container()` on it to enter
`ACTION` scope:

Building on the first example's `app` and `container`:

```python
import modern_di
import modern_di_typer
import typing
from modern_di import Group, Scope, providers


class Job:
def run(self) -> None: ...


class AppGroup(Group):
job = providers.Factory(scope=Scope.ACTION, creator=..., bound_type=None)
job = providers.Factory(scope=Scope.ACTION, creator=Job, bound_type=None)


@app.command()
Expand Down
25 changes: 14 additions & 11 deletions docs/introduction/about-di.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,28 +65,31 @@ service = UserService(cache=MockCache()) # Testing

## Lifetime Management in DI

Objects can have different lifetime cycles (singleton, scoped, transient). `modern-di` expresses this with [Scopes](../providers/scopes.md) — APP for process-wide singletons, REQUEST for per-request resources, and so on. Here are examples:
Objects can have different lifetime cycles (singleton, scoped, transient). `modern-di` expresses this with [Scopes](../providers/scopes.md) — APP for process-wide singletons, REQUEST for per-request resources, and so on. In modern-di's own terms: a provider's scope (APP/SESSION/REQUEST/…) decides how long an instance lives, and the presence of `cache_settings` means one shared instance is reused while its absence means a fresh instance is created on each resolve. Here are examples:

```python

import uuid

from modern_di import Group, Scope, providers

class AppModule(Group):
# Singleton: one instance for entire app
class AppGroup(Group):
# Cached for the whole app: one shared instance.
# CacheSettings() makes the provider a cached singleton (see the Factories page).
config = providers.Factory(
scope=Scope.APP,
creator=AppConfig,
cache_settings=providers.CacheSettings()
)

# Scoped: one instance per request
# Cached per request: one shared instance per request
db_session = providers.Factory(
scope=Scope.REQUEST,
creator=DatabaseSession,
cache_settings=providers.CacheSettings()
)

# Transient: new instance each time
# No cache_settings: a fresh instance each resolve
request_id = providers.Factory(
scope=Scope.REQUEST,
creator=lambda: str(uuid.uuid4())
Expand Down Expand Up @@ -130,7 +133,7 @@ class UserService:


# Declare dependencies
class AppModule(Group):
class AppGroup(Group):
config = providers.Factory(
scope=Scope.APP,
creator=AppConfig,
Expand All @@ -141,7 +144,7 @@ class AppModule(Group):


# Build the app-level container, then a request-scoped child for per-request providers
app_container = Container(scope=Scope.APP, groups=[AppModule])
app_container = Container(scope=Scope.APP, groups=[AppGroup])
with app_container.build_child_container(scope=Scope.REQUEST) as request_container:
user_service = request_container.resolve(UserService)
```
Expand All @@ -157,7 +160,7 @@ Type annotations auto-wire dependencies - no manual registration needed.
Hierarchical containers with automatic inheritance:

```python
app_container = Container(groups=[AppModule], scope=Scope.APP)
app_container = Container(groups=[AppGroup], scope=Scope.APP)
request_container = app_container.build_child_container(scope=Scope.REQUEST)

# Resolves from correct scope automatically
Expand All @@ -172,8 +175,8 @@ Override any dependency:
```python
@pytest.fixture
def test_container() -> Container:
container = Container(groups=[AppModule])
container.override(AppModule.db, Mock(spec=DatabaseConnection))
container = Container(groups=[AppGroup])
container.override(AppGroup.db, Mock(spec=DatabaseConnection))
return container
```

Expand All @@ -182,7 +185,7 @@ def test_container() -> Container:
Define finalizers for automatic cleanup:

```python
class AppModule(Group):
class AppGroup(Group):
db_session = providers.Factory(
scope=Scope.REQUEST,
creator=DatabaseSession,
Expand Down
2 changes: 1 addition & 1 deletion docs/introduction/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The codebase is type-checked with `ty` and linted with ruff's full rule set (`se

## 5. Conservative feature set

New features get added only when existing primitives genuinely cannot solve the task. The core has five providers (`Factory`, `Alias`, `ContextProvider`, `container_provider`, `AbstractProvider`) — most other DI frameworks have two to three times that. This is deliberate: a small, composable core is easier to learn, easier to test, and easier to keep correct.
New features get added only when existing primitives genuinely cannot solve the task. The core has three concrete provider types (`Factory`, `Alias`, `ContextProvider`), plus the `AbstractProvider` base and the pre-built `container_provider` singleton — most other DI frameworks have two to three times that. This is deliberate: a small, composable core is easier to learn, easier to test, and easier to keep correct.

## See also

Expand Down
5 changes: 3 additions & 2 deletions docs/introduction/resolving.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ class DatabaseConnection:

class Dependencies(Group):
db_config = providers.Factory(
scope=Scope.APP,
creator=DatabaseConfig,
kwargs={"host": "localhost", "port": 5432},
)
db_connection = providers.Factory(creator=DatabaseConnection)
db_connection = providers.Factory(scope=Scope.APP, creator=DatabaseConnection)


container = Container(groups=[Dependencies], validate=True)
Expand All @@ -43,7 +44,7 @@ assert connection.config.host == "localhost"
assert connection.timeout == 30
```

For union-typed parameters (`dep: A | B`), the resolver picks the *first* type in the union that has a registered provider. If you need a specific one, use a concrete annotation or pass the value explicitly via `kwargs`.
For union-typed parameters (`dep: A | B`), the resolver picks the *first* type in the union that has a registered provider. If you need a specific one, use a concrete annotation or pass the value explicitly via `kwargs`. A parameter typed `X | None` with no matching provider and no default value receives `None` rather than raising (see [Factories: Optional parameters](../providers/factories.md)).

## See also

Expand Down
4 changes: 2 additions & 2 deletions docs/migration/to-2.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,10 @@ with container.build_child_container(context=context, scope=Scope.REQUEST) as re
```python
# Same context-manager form continues to work
with container.build_child_container(context=context, scope=Scope.REQUEST) as request_container:
# Use request_container
service = request_container.resolve(MyService)

async with container.build_child_container(context=context, scope=Scope.REQUEST) as request_container:
# Use request_container
service = request_container.resolve(MyService)

# If you need manual lifecycle control, call close_sync() or await close_async() yourself
request_container = container.build_child_container(context=context, scope=Scope.REQUEST)
Expand Down
4 changes: 2 additions & 2 deletions docs/providers/alias.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The type the alias is registered under in the providers registry — i.e. the ty

### scope

**Deprecated and ignored.** An alias's effective scope is derived from its source provider — the alias itself holds no instance and applies no caching. Passing `scope=` to `Alias(...)` emits a `DeprecationWarning`; the parameter will be removed in 3.0.
**Deprecated and ignored.** An alias's effective scope is derived from its source provider — the alias itself holds no instance and applies no caching. Passing `scope=` to `Alias(...)` emits a `DeprecationWarning`; the parameter will be removed in a future release.

## Basic Usage

Expand Down Expand Up @@ -68,7 +68,7 @@ With an uncached source `Factory`, each resolution still goes through the source

## Overrides

Overrides are keyed by `provider_id`, so the alias and its source can be overridden independently:
Overrides are keyed by `provider_id`, so the alias and its source can be overridden independently. See [Testing with overrides](../recipes/testing-overrides.md) for the `container.override` / `reset_override` primitives.

```python
mock_for_alias = PostgresRepository(dsn="alias-mock")
Expand Down
Loading