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 .github/scripts/report-scheduled-failure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ if [ -z "$existing" ]; then
body=$(printf '%s\n\n%s\n\n%s\n\n%s' \
"The scheduled dependency check failed." \
"First failing run: ${RUN_URL}" \
"Likely cause: a transitive dev or lint dependency (ruff, ty, eof-fixer, pytest, typing-extensions) released a breaking change. Reproduce locally with \`just install\` then \`just lint\` and \`just test\`." \
"Likely cause: a transitive dev or lint dependency (ruff, ty, eof-fixer, pytest) released a breaking change. Reproduce locally with \`just install\` then \`just lint\` and \`just test\`." \
"Close this issue once fixed. The next scheduled failure will open a fresh issue.")
gh issue create --title "$TITLE" --label "$LABEL" --body "$body"
else
Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ jobs:
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install 3.10
- run: uv python pin 3.10
- run: uv python install 3.11
- run: uv python pin 3.11
- run: just install lint-ci
- run: just adr-check

Expand All @@ -23,7 +23,6 @@ jobs:
fail-fast: false
matrix:
python-version:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
- Automatic dependency graph based on type annotations
- Also, explicit dependencies are allowed where needed
- Scopes and context management
- Python 3.10+ support
- Python 3.11+ support
- Fully typed and tested
- Integrations with `aiogram`, `aiohttp`, `arq`, `Celery`, `FastAPI`, `FastStream`, `Flask`, `gRPC`, `Litestar`, `Starlette`, `taskiq`, and `Typer`
- Pytest integration (`modern-di-pytest`) that turns any DI dependency into a pytest fixture
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/comparative/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "modern-di-benchmarks-comparative"
version = "0"
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"modern-di",
"pytest",
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
- Automatic dependency graph based on type annotations
- Also, explicit dependencies are allowed where needed
- Scopes and context management
- Python 3.10+ support
- Python 3.11+ support
- Fully typed and tested
- Integrations with `aiogram`, `aiohttp`, `arq`, `Celery`, `FastAPI`, `FastStream`, `Flask`, `gRPC`, `Litestar`, `Starlette`, `taskiq`, `Typer`, and `pytest`

Expand Down
20 changes: 8 additions & 12 deletions modern_di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@
from modern_di.scope import Scope, _next_deeper


if typing.TYPE_CHECKING:
import typing_extensions


def _handle_recursion_error(
provider: AbstractProvider[typing.Any], container: "Container", exc: RecursionError
) -> typing.NoReturn:
Expand Down Expand Up @@ -77,7 +73,7 @@ class Container:
def __init__( # noqa: PLR0913, PLR0917
self,
scope: enum.IntEnum = Scope.APP,
parent_container: typing.Optional["typing_extensions.Self"] = None,
parent_container: typing.Self | None = None,
context: dict[type[typing.Any], typing.Any] | None = None,
groups: list[type[Group]] | None = None,
use_lock: bool = True,
Expand Down Expand Up @@ -107,7 +103,7 @@ def __init__( # noqa: PLR0913, PLR0917
# Ancestors only, never self: a `scope: self` entry is a reference cycle, so no container
# would ever be freed by refcounting.
# SLF001 exempts `self`/`cls` only, so it flags this same-class read; no boundary is crossed.
self._scope_map: dict[enum.IntEnum, typing_extensions.Self] = (
self._scope_map: dict[enum.IntEnum, typing.Self] = (
{**parent_container._scope_map, parent_container.scope: parent_container} # noqa: SLF001
if parent_container
else {}
Expand Down Expand Up @@ -138,15 +134,15 @@ def build_child_container(
*,
scope: enum.IntEnum | None = None,
context: dict[type[typing.Any], typing.Any] | None = None,
) -> "typing_extensions.Self":
) -> typing.Self:
if scope is None:
scope = _next_deeper(self.scope)
if scope is None:
raise exceptions.MaxScopeReachedError(parent_scope=self.scope)

return self.__class__(scope=scope, parent_container=self, context=context)

def find_container(self, scope: enum.IntEnum) -> "typing_extensions.Self":
def find_container(self, scope: enum.IntEnum) -> typing.Self:
if scope == self.scope:
return self
target = self._scope_map.get(scope)
Expand All @@ -157,7 +153,7 @@ def find_container(self, scope: enum.IntEnum) -> "typing_extensions.Self":
return target

@property
def scope_map(self) -> "dict[enum.IntEnum, typing_extensions.Self]":
def scope_map(self) -> dict[enum.IntEnum, typing.Self]:
warnings.warn(
"`Container.scope_map` is private; it will be removed in a future release.",
DeprecationWarning,
Expand Down Expand Up @@ -300,21 +296,21 @@ def _prepare(self) -> None:
)
self.closed = False

def __enter__(self) -> "typing_extensions.Self":
def __enter__(self) -> typing.Self:
self.open()
return self

def __exit__(self, *_: object) -> None:
self.close_sync()

async def __aenter__(self) -> "typing_extensions.Self":
async def __aenter__(self) -> typing.Self:
self.open()
return self

async def __aexit__(self, *_: object) -> None:
await self.close_async()

def __copy__(self, *_: object, **__: object) -> "typing_extensions.Self":
def __copy__(self, *_: object, **__: object) -> typing.Self:
"""Never clone: a copied container would own a detached cache whose finalizers never run."""
return self

Expand Down
6 changes: 1 addition & 5 deletions modern_di/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,8 @@
from modern_di.providers.abstract import AbstractProvider


if typing.TYPE_CHECKING:
import typing_extensions


class Group:
def __new__(cls, *_: typing.Any, **__: typing.Any) -> "typing_extensions.Self": # noqa: ANN401
def __new__(cls, *_: typing.Any, **__: typing.Any) -> typing.Self: # noqa: ANN401
raise exceptions.GroupInstantiationError(group_name=cls.__name__)

_default_scope: typing.ClassVar["enum.IntEnum | None"] = None
Expand Down
2 changes: 1 addition & 1 deletion modern_di/registries/cache_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def close_sync(self) -> None:
for cache_item in reversed(self._creation_order):
try:
cache_item.close_sync()
except exceptions.AsyncFinalizerInSyncCloseError as e: # noqa: PERF203
except exceptions.AsyncFinalizerInSyncCloseError as e:
finalizer_errors.append(e)
remaining.append(cache_item)
except Exception as e: # noqa: BLE001
Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@
name = "modern-di"
description = "Powerful dependency-injection framework with IoC container and scopes"
authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]
requires-python = ">=3.10,<4"
requires-python = ">=3.11,<4"
license = "MIT"
readme = "README.md"
keywords = ["dependency-injection", "di", "ioc-container", "scopes", "mocks", "python", "asyncio", "modern-di"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand Down Expand Up @@ -47,7 +46,6 @@ lint = [
"ruff",
"ty",
"eof-fixer",
"typing-extensions",
]

[tool.ty.src]
Expand Down
26 changes: 13 additions & 13 deletions tests/providers/test_context_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class MyGroup(Group):


def test_context_provider() -> None:
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app_container = Container(groups=[MyGroup], context={datetime.datetime: now})
app_container.open()
instance1 = app_container.resolve_provider(MyGroup.context_provider)
Expand All @@ -38,7 +38,7 @@ def test_context_provider() -> None:


def test_context_provider_set_context_after_creation() -> None:
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app_container = Container()
app_container.open()
app_container.set_context(datetime.datetime, now)
Expand Down Expand Up @@ -67,7 +67,7 @@ def test_context_provider_not_found_but_required() -> None:


def test_context_provider_in_request_scope() -> None:
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app_container = Container()
app_container.open()
request_container = app_container.build_child_container(context={datetime.datetime: now}, scope=Scope.REQUEST)
Expand Down Expand Up @@ -132,7 +132,7 @@ class NoneGroup(Group):


def test_factory_uses_default_when_context_provider_value_unset() -> None:
default = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
default = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)

@dataclasses.dataclass(kw_only=True, slots=True)
class TsHolder:
Expand Down Expand Up @@ -178,7 +178,7 @@ def test_set_context_after_first_resolve_is_seen_by_later_resolves() -> None:


def test_context_provider_through_closed_owning_container_warns() -> None:
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app = Container(groups=[MyGroup], context={datetime.datetime: now})
app.open()
child = app.build_child_container(scope=Scope.REQUEST)
Expand All @@ -192,7 +192,7 @@ def test_context_provider_through_closed_owning_container_warns() -> None:
def test_context_provider_does_not_prepare_an_already_open_container(monkeypatch: pytest.MonkeyPatch) -> None:
# `_prepare()` takes the container's RLock before re-checking `closed`, so calling it
# unconditionally would serialize every context resolve on the owning container's lock.
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app = Container(groups=[MyGroup], context={datetime.datetime: now})
app.open()

Expand Down Expand Up @@ -359,7 +359,7 @@ def test_direct_resolve_unset_context_raises() -> None:


def test_set_context_provider_direct_resolve_does_not_warn() -> None:
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app_container = Container(groups=[MyGroup], context={datetime.datetime: now})
app_container.open()
with warnings.catch_warnings():
Expand All @@ -369,7 +369,7 @@ def test_set_context_provider_direct_resolve_does_not_warn() -> None:

def test_context_provider_accepts_positional_context_type() -> None:
provider = providers.ContextProvider(datetime.datetime)
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app_container = Container(context={datetime.datetime: now})
app_container.open()
assert app_container.resolve_provider(provider) is now
Expand All @@ -383,7 +383,7 @@ def test_context_provider_rejects_context_type_passed_twice() -> None:
def test_context_provider_override_direct_short_circuits() -> None:
# An override of a ContextProvider compiles to a constant resolver, so resolving it directly
# returns the override with no ContextValueNotSetError, even with nothing in the registry.
override_value = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
override_value = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)
app_container = Container(groups=[MyGroup])
app_container.open()
app_container.override(MyGroup.context_provider, override_value)
Expand All @@ -392,7 +392,7 @@ def test_context_provider_override_direct_short_circuits() -> None:
assert app_container.resolve_provider(MyGroup.context_provider) is override_value


_SENTINEL_DEFAULT = datetime.datetime(1999, 9, 9, tzinfo=datetime.timezone.utc)
_SENTINEL_DEFAULT = datetime.datetime(1999, 9, 9, tzinfo=datetime.UTC)


def _ctx_default_creator(*, ctx: datetime.datetime | None = _SENTINEL_DEFAULT) -> str:
Expand Down Expand Up @@ -430,14 +430,14 @@ def test_kwargs_context_provider_matches_by_type_wiring() -> None:


def test_kwargs_context_provider_injects_present_value() -> None:
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app_container = Container(groups=[_KwargsCtxExplicitGroup], context={datetime.datetime: now})
app_container.open()
assert app_container.resolve_provider(_KwargsCtxExplicitGroup.out) == f"got {now!r}"


def test_kwargs_context_provider_override_wins() -> None:
override_value = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
override_value = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)
app_container = Container(groups=[_KwargsCtxExplicitGroup])
app_container.open()
app_container.override(_KwargsCtxExplicitGroup.ctx, override_value)
Expand Down Expand Up @@ -468,7 +468,7 @@ def test_kwargs_context_provider_without_parsed_signature_keeps_direct_resolve()
def test_kwargs_context_provider_without_parsed_signature_injects_present_value() -> None:
# Same no-parsed-signature routing as above, but with a value present: the direct-resolve path
# returns it normally and the creator runs.
now = datetime.datetime.now(tz=datetime.timezone.utc)
now = datetime.datetime.now(tz=datetime.UTC)
app_container = Container(groups=[_KwargsCtxNoSignatureGroup], context={datetime.datetime: now})
app_container.open()
assert app_container.resolve_provider(_KwargsCtxNoSignatureGroup.out) == f"ctx={now!r}"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def test_modern_di_imports_without_typing_extensions() -> None:
"""modern-di advertises zero runtime dependencies; `typing_extensions` must be type-checking only.

Runs in a fresh subprocess with `typing_extensions` import blocked, to catch any
unconditional runtime `import typing_extensions` (see container.py / group.py).
unconditional runtime `import typing_extensions`.
"""
code = (
"import sys\n"
Expand Down
22 changes: 8 additions & 14 deletions tests/test_runtime_cycle_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,11 @@ def test_unvalidated_cycle_raises_circular_dependency_error() -> None:
sys.setrecursionlimit(_SHALLOW_RECURSION_LIMIT)
try:
container.resolve(NodeA)
# CPython suspends trace-function calls for a few frames while unwinding a RecursionError;
# coverage.py can then under-report lines that run immediately during/after that recovery —
# a known CPython/coverage.py interaction (not a real execution gap: these lines run on
# every pass of this test, or the test would error/fail instead of passing).
except exceptions.CircularDependencyError as exc: # pragma: no cover - 3.10 coverage loses it after RecursionError
except exceptions.CircularDependencyError as exc:
_assert_simple_cycle(exc)
else: # pragma: no cover - runs only if the cycle guard stops raising
pytest.fail("expected CircularDependencyError")
finally: # pragma: no cover - 3.10 coverage loses it after RecursionError
finally:
sys.setrecursionlimit(original_limit)


Expand All @@ -133,12 +129,11 @@ def test_deep_chain_cycle_is_self_contained() -> None:
sys.setrecursionlimit(_SHALLOW_RECURSION_LIMIT)
try:
container.resolve(Root)
# See `test_unvalidated_cycle_raises_circular_dependency_error` for why this is `no cover`.
except exceptions.CircularDependencyError as exc: # pragma: no cover - 3.10 coverage loses it after RecursionError
except exceptions.CircularDependencyError as exc:
_assert_deep_chain_cycle_is_self_contained(exc)
else: # pragma: no cover - runs only if the cycle guard stops raising
pytest.fail("expected CircularDependencyError")
finally: # pragma: no cover - 3.10 coverage loses it after RecursionError
finally:
sys.setrecursionlimit(original_limit)


Expand Down Expand Up @@ -217,12 +212,11 @@ class G(Group):
sys.setrecursionlimit(80)
try:
container.resolve(_CanonicalA)
# See `test_unvalidated_cycle_raises_circular_dependency_error` for why this is `no cover`.
except exceptions.CircularDependencyError as exc: # pragma: no cover - 3.10 coverage loses it after RecursionError
except exceptions.CircularDependencyError as exc:
_assert_cycle_is_canonical_and_self_contained(exc)
else: # pragma: no cover - runs only if the cycle guard stops raising
pytest.fail("expected CircularDependencyError")
finally: # pragma: no cover - 3.10 coverage loses it after RecursionError
finally:
sys.setrecursionlimit(limit)


Expand All @@ -239,11 +233,11 @@ def test_by_reference_cycle_raises_circular_dependency_error() -> None:
sys.setrecursionlimit(_SHALLOW_RECURSION_LIMIT)
try:
container.resolve_provider(CycleGroup.a)
except exceptions.CircularDependencyError as exc: # pragma: no cover - 3.10 coverage loses it after RecursionError
except exceptions.CircularDependencyError as exc:
_assert_simple_cycle(exc)
else: # pragma: no cover - runs only if the cycle guard stops raising
pytest.fail("expected CircularDependencyError")
finally: # pragma: no cover - 3.10 coverage loses it after RecursionError
finally:
sys.setrecursionlimit(original_limit)


Expand Down
Loading