diff --git a/.github/scripts/report-scheduled-failure.sh b/.github/scripts/report-scheduled-failure.sh index 1a0f5f72..855c6ff0 100755 --- a/.github/scripts/report-scheduled-failure.sh +++ b/.github/scripts/report-scheduled-failure.sh @@ -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 diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 0a58fbec..189868f2 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -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 @@ -23,7 +23,6 @@ jobs: fail-fast: false matrix: python-version: - - "3.10" - "3.11" - "3.12" - "3.13" diff --git a/README.md b/README.md index e13ad87c..a2ce13be 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/benchmarks/comparative/pyproject.toml b/benchmarks/comparative/pyproject.toml index eeba3daf..19cf6d4c 100644 --- a/benchmarks/comparative/pyproject.toml +++ b/benchmarks/comparative/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "modern-di-benchmarks-comparative" version = "0" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "modern-di", "pytest", diff --git a/docs/index.md b/docs/index.md index a47f5a67..11422541 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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` diff --git a/modern_di/container.py b/modern_di/container.py index b8f21902..782a123d 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -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: @@ -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, @@ -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 {} @@ -138,7 +134,7 @@ 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: @@ -146,7 +142,7 @@ def build_child_container( 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) @@ -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, @@ -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 diff --git a/modern_di/group.py b/modern_di/group.py index 11d6a6fc..898bd80d 100644 --- a/modern_di/group.py +++ b/modern_di/group.py @@ -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 diff --git a/modern_di/registries/cache_registry.py b/modern_di/registries/cache_registry.py index eec2668c..a2ea72a0 100644 --- a/modern_di/registries/cache_registry.py +++ b/modern_di/registries/cache_registry.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 1bef0f7f..4872af6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -47,7 +46,6 @@ lint = [ "ruff", "ty", "eof-fixer", - "typing-extensions", ] [tool.ty.src] diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index 6251bc06..035b52fa 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -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) @@ -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) @@ -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) @@ -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: @@ -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) @@ -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() @@ -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(): @@ -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 @@ -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) @@ -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: @@ -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) @@ -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}" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 5ee40f7a..78fb91ed 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -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" diff --git a/tests/test_runtime_cycle_guard.py b/tests/test_runtime_cycle_guard.py index 6ae57f0b..9e3883b1 100644 --- a/tests/test_runtime_cycle_guard.py +++ b/tests/test_runtime_cycle_guard.py @@ -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) @@ -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) @@ -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) @@ -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)