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
97 changes: 97 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

`modern-di` is a **zero-dependency** Python dependency injection framework that wires up object graphs from type annotations, manages lifetimes via hierarchical scopes, and supports both sync and async finalizers. This repo contains the core package plus framework integrations (FastAPI, FastStream, LiteStar), each independently versioned and published to PyPI.

## Commands

This project uses `just` (task runner) and `uv` (package manager).

```bash
just install # uv lock --upgrade && uv sync --all-extras --frozen --group lint
just lint # eof-fixer + ruff format + ruff check --fix + ty check
just lint-ci # same checks without auto-fixing (used in CI)
just test # uv run pytest (with coverage by default)
just test-branch # pytest with branch coverage
```

Run a single test file:
```bash
uv run pytest tests/providers/test_factory.py
```

Run a specific test by name:
```bash
uv run pytest tests/providers/test_factory.py -k test_name
```

Without `just`:
```bash
uv run ruff format . && uv run ruff check . --fix && uv run ty check
uv run pytest
```

## Architecture

### Scope hierarchy

`Scope` is an `IntEnum` with five levels: `APP=1 → SESSION=2 → REQUEST=3 → ACTION=4 → STEP=5`. Providers are bound to a scope; a provider can only be resolved from a container of the same or deeper (higher int) scope. Trying to resolve a REQUEST-scoped provider from an APP container raises a clear error.

### Container tree

`Container` is the central object. A root container is created with `Container(scope=Scope.APP, groups=[MyGroup])`. Child containers are created via `container.build_child_container(scope=Scope.REQUEST, context={...})`. Child containers share the parent's `providers_registry` and `overrides_registry` but have their own `cache_registry` and `context_registry`.

### Group and Provider declaration

`Group` is a namespace class (cannot be instantiated) used to declare providers as class-level attributes:

```python
class MyGroup(Group):
my_service = providers.Factory(scope=Scope.APP, creator=MyService)
```

`Factory` parses the `creator`'s `__init__` type hints at declaration time via `types_parser.parse_creator()`. During resolution it looks up each parameter type in `providers_registry` and recursively resolves dependencies.

### Resolution flow

1. `container.resolve(SomeType)` → looks up type in `providers_registry` → calls `resolve_provider(provider)`
2. `resolve_provider` checks `overrides_registry` first (returns override immediately if found)
3. Finds the container at the correct scope via `find_container(scope)` walking the parent chain
4. Checks `cache_registry`; if cached, returns immediately
5. Compiles kwargs: for each parsed parameter, finds a matching provider by type and resolves it recursively
6. Calls the creator, stores result in cache if `cache_settings` configured

### Registries

| Registry | Shared? | Purpose |
|---|---|---|
| `ProvidersRegistry` | Shared across all containers | type → provider mapping |
| `CacheRegistry` | Per-container | provider_id → cached instance |
| `ContextRegistry` | Per-container | type → runtime context object |
| `OverridesRegistry` | Shared across all containers | provider_id → override object (for testing) |

### Key files

- `modern_di/container.py` — Container class, the main entry point
- `modern_di/providers/factory.py` — Factory provider with caching and finalizer support
- `modern_di/types_parser.py` — Signature introspection engine (parses type hints for DI wiring)
- `modern_di/scope.py` — Scope enum
- `modern_di/group.py` — Group base class for provider namespaces

### Testing patterns

- Create a `Group` subclass with providers as class attributes, pass to `Container(groups=[...])`
- Use `container.resolve_provider(provider)` (by reference) or `container.resolve(SomeType)` (by type)
- For overrides: `container.override(provider, mock_obj)` / `container.reset_override(provider)`
- For scope chain tests: `app_container.build_child_container(scope=Scope.REQUEST)`
- `asyncio_mode = "auto"` in pytest config — async test functions work without extra markers

## Code Style

- Line length: 120 characters
- `ruff` with `select = ["ALL"]` and minimal ignores; `ty` for type checking
- Coverage excludes `TYPE_CHECKING` blocks
- Design principle: conservative feature set; resolution is sync-only (async was removed in 2.x); no global state
2 changes: 1 addition & 1 deletion docs/dev/decisions.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Decisions
1. Dependency resolving is sync only (since 2.x version)
2. Cached factories are thread-safe for concurrent resolving (`threading.Lock` is used)
3. No global state -> all state lives in registries of containers:
3. No global state -> all state lives in registries of containers
4. Focus on maximum type safety:
- No need for `# type: ignore`
- No need for `typing.cast`
Expand Down
5 changes: 5 additions & 0 deletions docs/dev/key-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,18 @@ Container provides methods for resolving dependencies:

1. `resolve_provider(provider)` - Resolve a specific provider instance
2. `resolve(SomeType)` - Resolve by type
3. `validate_provider(provider)` - Validate that the provider's dependency graph is wired correctly without creating real instances (useful at startup)

Container also provides methods for overriding providers with objects:

1. `override(provider, override_object)` - Override a provider with a mock object for testing
2. `reset_override(provider)` - Reset override for a specific provider
3. `reset_override()` - Reset all overrides

Container provides methods for injecting context values after creation:

1. `set_context(context_type, obj)` - Inject a context object into the container's context registry; equivalent to passing `context={context_type: obj}` to `build_child_container`

When resolving by type, the container looks for a provider that was registered with a matching `bound_type`.

The container itself can also be resolved as a dependency using `container.resolve(Container)`, which returns the same container instance.
Expand Down
10 changes: 4 additions & 6 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Welcome to the `modern-di` documentation!

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

- Automatic dependencies graph based on type annotations
- Scopes and context management
Expand Down Expand Up @@ -123,10 +123,8 @@ try:
instance4 = request_container.resolve_provider(Dependencies.dependent_factory)
# Use your instances...
finally:
# Close container when done
# For async usage:
await request_container.close_async()

# For sync usage:
# Close container when done (choose one depending on your context):
request_container.close_sync()
# or, in an async context:
# await request_container.close_async()
```
4 changes: 2 additions & 2 deletions docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ async def websocket_endpoint(
websocket: fastapi.WebSocket,
session_container: typing.Annotated[modern_di.Container, fastapi.Depends(modern_di_fastapi.build_di_container)],
) -> None:
await websocket.accept()
request_container = session_container.build_child_container(scope=modern_di.Scope.REQUEST)
# REQUEST scope is entered here
try:
# You can resolve dependencies here
await websocket.send_text("test")
finally:
await request_container.close_async()

await websocket.accept()
await websocket.send_text("test")
await websocket.close()
```

Expand Down
4 changes: 4 additions & 0 deletions docs/migration/to-1.x.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Migration Guide: Upgrading to modern-di 1.x

!!! warning "Historical guide"
This guide covers migrating from 0.x to 1.x. The APIs shown here (`AsyncContainer`, `SyncContainer`, `providers.Singleton`, `.cast`) were **removed in 2.x**.
If you are on 1.x today, also follow the [2.x migration guide](to-2.x.md) to reach the current API.

This document describes the changes required to migrate from modern-di 0.x versions to modern-di 1.x.

## Overview
Expand Down
13 changes: 8 additions & 5 deletions docs/providers/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,9 @@ ALL_GROUPS = [Dependencies]
app = fastapi.FastAPI()
container = Container(groups=ALL_GROUPS)
modern_di_fastapi.setup_di(app, container)


# Resolving by type
request_info_dict = container.resolve(dict)
# The integration creates a REQUEST-scoped child container per request and
# automatically injects the fastapi.Request into its context. The factory
# is resolved from the child container, not the APP-scope container.
```

## Manual `ContextProvider` Usage
Expand Down Expand Up @@ -77,12 +76,16 @@ class Dependencies(Group):
creator=create_user_info,
)


# Provide custom context when building container
container = Container(groups=[Dependencies])
custom_context = CustomContext(user_id="123", tenant_id="abc")
request_container = container.build_child_container(
scope=Scope.REQUEST,
context={CustomContext: custom_context}
)

# Now resolve the factory — it will receive the custom context automatically
user_info = request_container.resolve_provider(Dependencies.user_info)
# {"user_id": "123", "tenant_id": "abc"}
```
18 changes: 13 additions & 5 deletions docs/providers/factories.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Use `providers.CacheSettings()` to enable caching with optional cleanup configur
Disables automatic dependency resolution. When `True`:
- No automatic dependency resolution occurs
- All parameters must be provided via the `kwargs` parameter
- The `bound_type` will not be automatically inferred and defaults to `None`
- The `bound_type` will not be automatically inferred from the creator's return type; unless `bound_type` is explicitly provided, it defaults to `None`

## Types of factories
There are two types of factories:
Expand Down Expand Up @@ -110,7 +110,7 @@ class Dependencies(Group):
)


container = Container()
container = Container(groups=[Dependencies])
singleton_instance1 = container.resolve_provider(Dependencies.singleton)
singleton_instance2 = container.resolve_provider(Dependencies.singleton)

Expand All @@ -123,20 +123,28 @@ assert singleton_instance1 is singleton_instance2
You can customize caching behavior with `CacheSettings`:

```python
import contextlib

from modern_di import Group, Scope, providers


class SomeResource:
def close(self) -> None: ...


def create_resource() -> SomeResource:
# Create and return resource
pass
return SomeResource()


class Dependencies(Group):
# Cache with cleanup
# Cache with cleanup — clear_cache=True (the default) ensures the closed
# resource is evicted from cache so it cannot be returned again after close
resource = providers.Factory(
scope=Scope.APP,
creator=create_resource,
cache_settings=providers.CacheSettings(
finalizer=lambda res: res.close(), # Cleanup function
clear_cache=False # Keep cache after close
)
)
```
5 changes: 4 additions & 1 deletion docs/testing/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import modern_di_fastapi
import pytest

from app import ioc
from app.ioc import Dependencies, SimpleFactory


# The application object can be imported from somewhere
Expand Down Expand Up @@ -39,11 +40,13 @@ async def request_di_container(di_container: modern_di.Container) -> typing.Asyn


@pytest.fixture
def mock_dependencies(di_container: modern_di.Container) -> None:
def mock_dependencies(di_container: modern_di.Container) -> typing.Iterator[None]:
di_container.override(
provider=Dependencies.simple_factory,
override_object=SimpleFactory(dep1="mock", dep2=777)
)
yield
di_container.reset_override(Dependencies.simple_factory)
```

## 2. Use fixtures in tests:
Expand Down
2 changes: 1 addition & 1 deletion modern_di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def resolve(self, dependency_type: type[types.T]) -> types.T:
return self.resolve_provider(provider)

def resolve_provider(self, provider: "AbstractProvider[types.T]") -> types.T:
if (override := self.overrides_registry.fetch_override(provider.provider_id)) is not None:
if (override := self.overrides_registry.fetch_override(provider.provider_id)) is not types.UNSET:
return typing.cast(types.T, override)

return typing.cast(types.T, provider.resolve(self))
Expand Down
2 changes: 1 addition & 1 deletion modern_di/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Group:
providers: list[AbstractProvider[typing.Any]]

def __new__(cls, *_: typing.Any, **__: typing.Any) -> "typing_extensions.Self": # noqa: ANN401
msg = f"{cls.__name__} cannot not be instantiated"
msg = f"{cls.__name__} cannot be instantiated"
raise RuntimeError(msg)

@classmethod
Expand Down
2 changes: 1 addition & 1 deletion modern_di/providers/context_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


class ContextProvider(AbstractProvider[types.T_co]):
__slots__ = [*AbstractProvider.BASE_SLOTS, "_context_type"]
__slots__ = [*AbstractProvider.BASE_SLOTS]

def __init__(
self,
Expand Down
8 changes: 6 additions & 2 deletions modern_di/registries/overrides_registry.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import dataclasses
import typing

from modern_di import types


T_co = typing.TypeVar("T_co", covariant=True)

_UNSET = object()


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class OverridesRegistry:
Expand All @@ -18,5 +22,5 @@ def reset_override(self, provider_id: str | None = None) -> None:
else:
self.overrides.pop(provider_id, None)

def fetch_override(self, provider_id: str) -> object | None:
return self.overrides.get(provider_id)
def fetch_override(self, provider_id: str) -> object:
return self.overrides.get(provider_id, types.UNSET)
2 changes: 1 addition & 1 deletion tests/test_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
def test_group_cannot_be_instantiated() -> None:
class Dependencies(Group): ...

with pytest.raises(RuntimeError, match="Dependencies cannot not be instantiated"):
with pytest.raises(RuntimeError, match="Dependencies cannot be instantiated"):
Dependencies()
Loading