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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ dist/
.python-version
.venv
uv.lock
plan.md
124 changes: 0 additions & 124 deletions AGENTS.md

This file was deleted.

21 changes: 11 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## 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.
`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. Framework integrations (FastAPI, FastStream, LiteStar) live in **separate repositories** and are published as separate PyPI packages.

## Commands

Expand All @@ -18,14 +18,10 @@ just test # uv run pytest (with coverage by default)
just test-branch # pytest with branch coverage
```

Run a single test file:
`just test` passes extra args to pytest:
```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
just test tests/providers/test_factory.py
just test tests/providers/test_factory.py -k test_name
```

Without `just`:
Expand Down Expand Up @@ -53,7 +49,9 @@ 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.
`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. There is no separate `Singleton` class — singleton behavior is `Factory(cache_settings=CacheSettings())`. Pass `kwargs={}` to supply static arguments that bypass type-based resolution. Pass `skip_creator_parsing=True` for callables whose signatures cannot be introspected.

`ContextProvider` is for runtime values injected at container creation time (e.g. a request object). `container_provider` is an auto-registered singleton that resolves to the `Container` itself.

### Resolution flow

Expand All @@ -76,10 +74,13 @@ class MyGroup(Group):
### 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/providers/factory.py` — Factory and CacheSettings (singleton pattern via caching + optional finalizer)
- `modern_di/providers/context_provider.py` — ContextProvider for runtime-injected values
- `modern_di/providers/container_provider.py` — auto-registered provider that resolves to the Container itself
- `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
- `modern_di/errors.py` — Error message templates

### Testing patterns

Expand Down
2 changes: 2 additions & 0 deletions docs/introduction/resolving.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ When a factory is created:
3. If a matching provider is found, it's automatically injected when the factory is resolved
4. If no matching provider is found and no default value is provided, an error is raised

For union-typed parameters (e.g. `dep: A | B`), the first type in the union that has a registered provider is used. If you need a specific type injected, use a concrete type annotation or supply the value explicitly via `kwargs`.

Example:

```python
Expand Down
3 changes: 3 additions & 0 deletions docs/migration/to-2.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ instance = container.resolve_provider(provider)
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.

## Migration Steps

1. **Update Dependencies**: Ensure all modern-di packages are updated to 2.x versions
Expand Down
14 changes: 13 additions & 1 deletion docs/providers/factories.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ Use this to provide specific values for parameters or override automatically res
Configuration for caching instances. Only applicable for cached factories.
Use `providers.CacheSettings()` to enable caching with optional cleanup configuration.

### Union type parameters

When a parameter is annotated with a union type (e.g. `dep: A | B`), Modern-DI resolves the **first registered type** that matches. The order is determined by how types appear in the union left-to-right. If you rely on a specific type being injected, prefer a concrete type annotation over a union.

### skip_creator_parsing

Disables automatic dependency resolution. When `True`:
Expand Down Expand Up @@ -90,7 +94,15 @@ assert isinstance(instance2, IndependentFactory)

Cached factories resolve the dependency only once and cache the resolved instance for future injections.

The caching mechanism is thread-safe, ensuring that even when multiple threads attempt to resolve the same cached factory simultaneously, only one instance will be created.
The caching mechanism is thread-safe by default, ensuring that even when multiple threads attempt to resolve the same cached factory simultaneously, only one instance will be created.

If your application is single-threaded, you can disable the lock for a small performance gain:

```python
container = Container(groups=[Dependencies], use_lock=False)
```

Do not set `use_lock=False` in multi-threaded applications — it removes the guarantee that only one instance is created per cached factory.

```python
import random
Expand Down
3 changes: 3 additions & 0 deletions docs/testing/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ def mock_dependencies(di_container: modern_di.Container) -> typing.Iterator[None
di_container.reset_override(Dependencies.simple_factory)
```

!!! note "Overrides are global"
`container.override()` and `container.reset_override()` operate on the shared overrides registry, which is shared across all containers in the same tree (parent and all children). Calling `override()` on a child container affects every container in the tree for the duration of the override. Always call `reset_override()` in a `finally` block or use a fixture that guarantees cleanup.

## 2. Use fixtures in tests:

```python
Expand Down
3 changes: 1 addition & 2 deletions modern_di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ def __init__(
self.overrides_registry = parent_container.overrides_registry
else:
self.providers_registry = ProvidersRegistry()
container_provider.bound_type = type(self)
self.providers_registry.add_providers(container_provider)
self.providers_registry.register(type(self), container_provider)
self.overrides_registry = OverridesRegistry()
if groups:
for one_group in groups:
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 @@ -17,7 +17,7 @@ def __init__(
*,
scope: Scope = Scope.APP,
context_type: type[types.T_co],
bound_type: type | None = types.UNSET, # type: ignore[assignment]
bound_type: type | None = types.UNSET, # ty: ignore[invalid-parameter-default]
) -> None:
super().__init__(scope=scope, bound_type=bound_type if bound_type != types.UNSET else context_type)
self._context_type = context_type
Expand Down
2 changes: 1 addition & 1 deletion modern_di/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def __init__( # noqa: PLR0913
*,
scope: Scope = Scope.APP,
creator: typing.Callable[..., types.T_co],
bound_type: type | None = types.UNSET, # type: ignore[assignment]
bound_type: type | None = types.UNSET, # ty: ignore[invalid-parameter-default]
kwargs: dict[str, typing.Any] | None = None,
cache_settings: CacheSettings[types.T_co] | None = None,
skip_creator_parsing: bool = False,
Expand Down
22 changes: 18 additions & 4 deletions modern_di/registries/cache_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def _clear(self) -> None:
async def close_async(self) -> None:
if self.cache and self.settings and self.settings.finalizer:
if self.settings.is_async_finalizer:
await self.settings.finalizer(self.cache) # type: ignore[misc]
await self.settings.finalizer(self.cache) # ty: ignore[invalid-await]
else:
self.settings.finalizer(self.cache)

Expand All @@ -39,17 +39,31 @@ def close_sync(self) -> None:
self._clear()


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@dataclasses.dataclass(kw_only=True, slots=True)
class CacheRegistry:
_items: dict[str, CacheItem] = dataclasses.field(init=False, default_factory=dict)

def fetch_cache_item(self, provider: Factory[types.T_co]) -> CacheItem:
return self._items.setdefault(provider.provider_id, CacheItem(settings=provider.cache_settings))

async def close_async(self) -> None:
errors: list[BaseException] = []
for cache_item in self._items.values():
await cache_item.close_async()
try:
await cache_item.close_async()
except Exception as e: # noqa: BLE001, PERF203
errors.append(e)
if errors:
msg = f"Errors during async cleanup: {errors}"
raise RuntimeError(msg)

def close_sync(self) -> None:
errors: list[BaseException] = []
for cache_item in self._items.values():
cache_item.close_sync()
try:
cache_item.close_sync()
except Exception as e: # noqa: BLE001, PERF203
errors.append(e)
if errors:
msg = f"Errors during sync cleanup: {errors}"
raise RuntimeError(msg)
2 changes: 1 addition & 1 deletion modern_di/registries/context_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from modern_di import types


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@dataclasses.dataclass(kw_only=True, slots=True)
class ContextRegistry:
context: dict[type[typing.Any], typing.Any]

Expand Down
2 changes: 1 addition & 1 deletion modern_di/registries/overrides_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
_UNSET = object()


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@dataclasses.dataclass(kw_only=True, slots=True)
class OverridesRegistry:
overrides: dict[str, typing.Any] = dataclasses.field(init=False, default_factory=dict)

Expand Down
14 changes: 8 additions & 6 deletions modern_di/registries/providers_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ def __init__(self) -> None:
def find_provider(self, dependency_type: type[types.T]) -> AbstractProvider[types.T] | None:
return self._providers.get(dependency_type)

def register(self, provider_type: type, provider: AbstractProvider[typing.Any]) -> None:
if provider_type in self._providers:
raise RuntimeError(errors.PROVIDER_DUPLICATE_TYPE_ERROR.format(provider_type=provider_type))

self._providers[provider_type] = provider

def add_providers(self, *args: AbstractProvider[typing.Any]) -> None:
for provider in args:
provider_type = provider.bound_type
if not provider_type:
if not provider.bound_type:
continue

if provider_type in self._providers:
raise RuntimeError(errors.PROVIDER_DUPLICATE_TYPE_ERROR.format(provider_type=provider_type))

self._providers[provider_type] = provider
self.register(provider.bound_type, provider)
Loading
Loading