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 docs/introduction/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The codebase is type-checked with `ty` and linted with ruff's full rule set (`se

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. The small core is deliberate, because a small, composable core is easier to learn, test, and keep correct.

The provider set is closed. `AbstractProvider` is the shared base that appears in signatures, not a hook: resolution compiles a resolver per known provider type, so a subclass of `AbstractProvider` or `Factory` raises `TypeError` at its first resolve. Compose behaviour in a creator function or an `Alias` instead.
The provider set is closed. `AbstractProvider` is the shared base that appears in signatures, not a hook: resolution compiles a resolver per known provider type, so defining a subclass of `AbstractProvider` or `Factory` raises `TypeError` when the class is created. Compose behaviour in a creator function or an `Alias` instead.

Caching is one argument, `Factory(cache=True | CacheSettings(...))`, rather than a `Singleton` class: a class would say "cached" in its name and again in the settings it still needs for a finalizer, and the two can drift.

Expand Down
6 changes: 5 additions & 1 deletion docs/migration/to-4.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ the finalizer errors inside it by type, `AsyncFinalizerInSyncCloseError` include
was a list.
- The constructor keyword `finalizer_errors=` is now `exceptions=`.
- `.is_async` is unchanged, and a group that `except*` splits off keeps it.
- `except FinalizerError` and `except ModernDIError` still catch it, and its message is unchanged.
- `except FinalizerError` and `except ModernDIError` still catch it.
- Its message is one line naming each kind of error with its count, such as
`Container.close_sync() found 1 finalizer error(s): ValueError (1)`, where 3.x listed every
finalizer exception. Each finalizer exception carries a note naming the type of the cached
instance whose finalizer raised it.

```python
# 3.x
Expand Down
8 changes: 4 additions & 4 deletions docs/providers/advanced-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ returns the same providers as a dict keyed by attribute name.
`Factory`, `Alias`, `ContextProvider`, and the pre-built `container_provider` are the
only provider types. `AbstractProvider` is their shared base and the type that appears
in public signatures (`resolve_dependency`, `kwargs=`), but it is not a hook for
adding your own: resolution compiles a resolver per known provider type, so a subclass
of `AbstractProvider` (or of `Factory`) raises `TypeError` at its first resolve, and
`validate()` does not catch it. Compose behavior in a creator function, or use `Alias`,
instead of introducing a provider type.
adding your own: resolution compiles a resolver per known provider type, so defining a
subclass of `AbstractProvider` (or of `Factory`) raises `TypeError` when the class is
created. Compose behavior in a creator function, or use `Alias`, instead of introducing a
provider type.

## Container navigation

Expand Down
7 changes: 4 additions & 3 deletions docs/providers/errors-and-exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ declared or registered, or by `validate()`, which reports `InvalidScopeDependenc
`ScopeEnumMismatchError`.

- `DuplicateProviderTypeError` is raised when two providers are registered for the same bound type
(within one group, across groups passed together, or against an already-registered type). See
(within one group, across groups passed together, or against an already-registered type).
`.first_provider` and `.second_provider` hold the two providers in registration order. See
[Troubleshooting: Duplicate type](../troubleshooting/duplicate-type-error.md).
- `ChildContainerRegistrationError` is raised by `Container.add_providers()` when called on a child
container. Registration is root-only because the providers registry is shared tree-wide, so
Expand Down Expand Up @@ -222,8 +223,8 @@ These don't fit the register/resolve/validate grouping:
## Security note

`modern-di` exception messages are intended for developers (logs, tracebacks during wiring). A
`CreatorCallError` embeds the wrapped exception's text, and a `FinalizerError` embeds the repr of every
`CreatorCallError` embeds the wrapped exception's text, and a `FinalizerError` traceback shows every
finalizer exception. So if a creator or finalizer raises an error whose message contains sensitive
runtime data, that text becomes part of the `modern-di` message. The DI-specific errors themselves are
runtime data, that text becomes part of what `modern-di` reports. The DI-specific errors themselves are
conservative (type names and provider reprs only; context values are keyed by type and never repr'd).
Applications must not echo raw exception strings to untrusted clients.
17 changes: 10 additions & 7 deletions docs/troubleshooting/duplicate-type-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ When you see this error:

```
DuplicateProviderTypeError: Provider is duplicated by type <class 'SomeType'>.
- Factory (myapp.ioc:12)
- Factory (myapp.other:30)
Set bound_type=None on one of them to make it resolvable by reference only.
See: https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/
```

The full runtime message also embeds the numbered resolution steps (set `bound_type=None` on one of the providers, or pass dependencies via `kwargs`) and a `See https://...` backlink to this page.
Each line names one of the two providers, the one registered first on top: a `Factory` by where its
creator is declared, any other provider by its repr. `.first_provider` and `.second_provider` hold
the provider objects.

It descends from `RegistrationError` → `ModernDIError` → `RuntimeError`, so `except DuplicateProviderTypeError`, `except RegistrationError`, and `except RuntimeError` all catch it. See [Errors and exceptions](../providers/errors-and-exceptions.md).

Expand All @@ -21,12 +27,9 @@ This typically happens when:

## Fix

To fix this error, you need to:

1. Set `bound_type=None` on one of the duplicate providers to make it unresolvable by type
2. Explicitly pass dependencies via the `kwargs` parameter to avoid automatic resolution

A complete example covering both steps:
Set `bound_type=None` on one of the duplicate providers to make it unresolvable by type. A factory
that needs that provider then gets it explicitly through `kwargs`, since resolving by type finds only
the other one. The example below does both:

```python
from modern_di import Group, Scope, providers
Expand Down
13 changes: 10 additions & 3 deletions docs/troubleshooting/finalizer-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

## Symptom

Raised by `close_sync()` / `close_async()`, embedding the list of finalizer exceptions that occurred
during cleanup and whether the close was sync or async. It is an `ExceptionGroup`, so a traceback
shows each finalizer exception below it.
Raised by `close_sync()` / `close_async()` when finalizers fail during cleanup. The message is one
line naming each kind of error with its count:

```
FinalizerError: Container.close_sync() found 2 finalizer error(s): ConnectionError (1), ValueError (1)
```

It is an `ExceptionGroup`, so a traceback shows each finalizer exception below it, with a note naming
the type of the cached instance whose finalizer raised it, such as
`raised by the finalizer of a cached Database`.

## Cause

Expand Down
3 changes: 3 additions & 0 deletions modern_di/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import typing

from modern_di import exceptions, types
from modern_di.exceptions.lifecycle import finalizer_note
from modern_di.providers import CacheSettings


Expand Down Expand Up @@ -55,6 +56,7 @@ async def close_async(creation_order: list[CacheItem]) -> None:
if result is not None and inspect.isawaitable(result):
await result
except Exception as e: # noqa: BLE001
e.add_note(finalizer_note(type(cache_item.cache)))
finalizer_errors.append(e)
else:
cache_item.finalized = True
Expand All @@ -79,6 +81,7 @@ def close_sync(creation_order: list[CacheItem]) -> None:
try:
result = finalizer(value)
except Exception as e: # noqa: BLE001
e.add_note(finalizer_note(type(value)))
finalizer_errors.append(e)
else:
if result is not None and inspect.isawaitable(result):
Expand Down
13 changes: 11 additions & 2 deletions modern_di/exceptions/lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Errors outside the register and resolve paths: closing a container, constructing a group."""

import collections
import typing
from collections.abc import Sequence

from modern_di.exceptions.base import ModernDIError
from modern_di.exceptions.rendering import type_name


class FinalizerError(ModernDIError, ExceptionGroup[Exception]):
Expand Down Expand Up @@ -32,8 +34,15 @@ def derive(self, excs: Sequence[Exception]) -> "FinalizerError":


def _cleanup_message(exceptions: Sequence[Exception], *, is_async: bool) -> str:
kind = "async" if is_async else "sync"
return f"Errors during {kind} cleanup: {list(exceptions)}"
counts = collections.Counter(type(error).__name__ for error in exceptions)
kinds = ", ".join(f"{kind} ({counts[kind]})" for kind in sorted(counts))
method = "close_async" if is_async else "close_sync"
return f"Container.{method}() found {len(exceptions)} finalizer error(s): {kinds}"


def finalizer_note(instance_type: type) -> str:
"""Return the note for an exception raised by the finalizer of a cached ``instance_type``."""
return f"raised by the finalizer of a cached {type_name(instance_type)}"


class AsyncFinalizerInSyncCloseError(ModernDIError):
Expand Down
30 changes: 20 additions & 10 deletions modern_di/exceptions/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from modern_di import suggester
from modern_di.exceptions.base import ModernDIError
from modern_di.exceptions.rendering import provider_step, render_chain, render_suggestion_lines
from modern_di.exceptions.rendering import provider_step, render_chain, render_provider_lines, render_suggestion_lines


if typing.TYPE_CHECKING:
Expand All @@ -19,20 +19,30 @@ class RegistrationError(ModernDIError):


class DuplicateProviderTypeError(RegistrationError):
"""Two providers were registered for the same ``.provider_type``."""
"""Two providers were registered for the same ``.provider_type``.

Inspect ``.first_provider`` (registered first) and ``.second_provider``; either is ``None`` when the
error was built without it.
"""

docs_slug = "duplicate-type-error"

__slots__ = ("provider_type",)
__slots__ = ("first_provider", "provider_type", "second_provider")

def __init__(self, *, provider_type: type) -> None:
def __init__(
self,
*,
provider_type: type,
first_provider: "AbstractProvider[typing.Any] | None" = None,
second_provider: "AbstractProvider[typing.Any] | None" = None,
) -> None:
self.provider_type = provider_type
super().__init__(
f"Provider is duplicated by type {provider_type}. "
"To resolve this issue:\n"
"1. Set bound_type=None on one of the providers to make it unresolvable by type\n"
"2. Explicitly pass dependencies via the kwargs parameter to avoid automatic resolution"
)
self.first_provider = first_provider
self.second_provider = second_provider
lines = [f"Provider is duplicated by type {provider_type}."]
lines.extend(render_provider_lines([p for p in (first_provider, second_provider) if p is not None]))
lines.append("Set bound_type=None on one of them to make it resolvable by reference only.")
super().__init__("\n".join(lines))


class ChildContainerRegistrationError(RegistrationError):
Expand Down
9 changes: 9 additions & 0 deletions modern_di/exceptions/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ def provider_step(provider: "AbstractProvider[typing.Any]", scope: enum.IntEnum
)


def render_provider_lines(providers: "list[AbstractProvider[typing.Any]]") -> list[str]:
"""Draw providers as a bulleted list: each by its kind and declaration site, else by its repr."""
lines = []
for provider in providers:
site = provider.definition_site
lines.append(f" - {type(provider).__name__} ({site})" if site else f" - {provider!r}")
return lines


def render_chain(steps: "list[ResolutionStep]") -> list[str]:
"""Draw a provider chain as an indented arrow tree, one line per step.

Expand Down
11 changes: 11 additions & 0 deletions modern_di/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@


class Group:
"""A namespace of providers, declared as class attributes and passed to ``Container(groups=[...])``.

``class G(Group, scope=Scope.REQUEST)`` gives that default scope to each ``Factory`` and
``ContextProvider`` declared in its body without its own ``scope=``. A group is never instantiated.
"""

def __new__(cls, *_: object, **__: object) -> typing.Self:
raise exceptions.GroupInstantiationError(group_name=cls.__name__)

Expand All @@ -27,6 +33,10 @@ def __init_subclass__(cls, scope: "enum.IntEnum | None" = None, **kwargs: object

@classmethod
def get_named_providers(cls) -> dict[str, AbstractProvider[typing.Any]]:
"""Return every provider declared on this group and its bases, keyed by attribute name.

A subclass attribute shadows a base attribute of the same name.
"""
seen_names: set[str] = set()
collected: dict[str, AbstractProvider[typing.Any]] = {}
for klass in cls.__mro__:
Expand All @@ -42,6 +52,7 @@ def get_named_providers(cls) -> dict[str, AbstractProvider[typing.Any]]:

@classmethod
def get_providers(cls) -> list[AbstractProvider[typing.Any]]:
"""Return the providers of :meth:`get_named_providers` as a list."""
# A direct subclass has no inherited providers to merge or shadow.
if cls.__bases__ == (Group,):
return [value for value in cls.__dict__.values() if isinstance(value, AbstractProvider)]
Expand Down
15 changes: 15 additions & 0 deletions modern_di/providers/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,26 @@


class AbstractProvider(typing.Generic[types.T_co]):
"""Shared base of ``Factory``, ``Alias``, ``ContextProvider`` and ``container_provider``.

It appears in signatures that accept any provider. The provider set is closed: subclassing it,
or any provider, outside modern-di raises ``TypeError``.
"""

__slots__ = ("_bound_type", "_explicit_scope", "_group_claim", "_provider_id", "_registered")

_takes_group_scope: typing.ClassVar[bool] = True
"""Whether a Group-level default scope applies. False when the effective scope is derived."""

def __init_subclass__(cls, **kwargs: object) -> None:
if cls.__module__.partition(".")[0] != "modern_di":
msg = (
f"{cls.__qualname__} subclasses a modern-di provider. The provider set is closed, so a "
"subclass would not resolve. Compose behavior in a creator function or an Alias instead."
)
raise TypeError(msg)
super().__init_subclass__(**kwargs)

def __init__(
self,
*,
Expand Down
8 changes: 8 additions & 0 deletions modern_di/providers/alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@


class Alias(AbstractProvider[types.T_co]):
"""Provider that resolves through whichever provider is registered for ``source_type``.

Register it under ``bound_type``, typically an abstract base or ``Protocol``, to make that type
resolve to an existing implementation. An alias holds no instance and has no scope of its own:
caching and scope come from the source provider. The alias and its source can be overridden
independently.
"""

__slots__ = ("_source_type",)

_takes_group_scope = False
Expand Down
17 changes: 17 additions & 0 deletions modern_di/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@

@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class CacheSettings(typing.Generic[types.T_contra]):
"""How a cached ``Factory`` keeps and releases its instance.

``finalizer`` runs on the instance when its container closes; it may be sync or async. With
``clear_cache=True``, the default, the instance is dropped at close and rebuilt on the next
resolve after reopening. With ``clear_cache=False`` the same instance survives close and reopen,
and its finalizer runs only once.
"""

clear_cache: bool = True
finalizer: typing.Callable[[types.T_contra], object] | None = None
_is_async_finalizer: bool = dataclasses.field(init=False, repr=False, compare=False)
Expand All @@ -37,6 +45,15 @@ def _coerce(cache: "bool | CacheSettings[types.T]") -> "CacheSettings[types.T] |


class Factory(AbstractProvider[types.T_co]):
"""Provider that calls ``creator`` to build its value, wiring its parameters from the container.

Each parameter is resolved by its annotated type unless ``kwargs`` gives it a value or a
provider. The bound type defaults to the creator's return annotation; ``bound_type=None`` makes
the provider resolvable by reference only. ``cache=True`` or a ``CacheSettings`` builds the value
once per container at the provider's scope. ``skip_creator_parsing=True`` turns off wiring, so
every required argument must come from ``kwargs``.
"""

__slots__ = (
"_cache_settings",
"_cached_definition_site",
Expand Down
14 changes: 10 additions & 4 deletions modern_di/registries/providers_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,11 @@ def add_providers(
if not provider.bound_type:
continue
if provider.bound_type in new_providers:
raise exceptions.DuplicateProviderTypeError(provider_type=provider.bound_type)
raise exceptions.DuplicateProviderTypeError(
provider_type=provider.bound_type,
first_provider=new_providers[provider.bound_type],
second_provider=provider,
)
new_providers[provider.bound_type] = provider
self._add(new_providers, (*bindings.values(), *args) if bindings else args)

Expand All @@ -153,9 +157,11 @@ def _add(
) -> None:
"""Bind ``new_providers`` and latch every provider in ``registered``; a type already bound raises."""
with self._lock:
for provider_type in new_providers:
if provider_type in self._providers:
raise exceptions.DuplicateProviderTypeError(provider_type=provider_type)
for provider_type, provider in new_providers.items():
if (existing := self._providers.get(provider_type)) is not None:
raise exceptions.DuplicateProviderTypeError(
provider_type=provider_type, first_provider=existing, second_provider=provider
)
self._providers.update(new_providers)
# Over `registered`: a reference-only provider never enters `_providers` but is still compiled.
for provider in registered:
Expand Down
Loading
Loading