diff --git a/.flake8 b/.flake8 index 0f6f5a8..93ae223 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] exclude = __pycache__,built,build,venv -ignore = E203, E266, W503 +ignore = E203, E266, W503, E701, E704 max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9632272..86820ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,8 +21,9 @@ jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v1 @@ -69,18 +70,18 @@ jobs: - name: Install distribution dependencies run: pip install --upgrade build - if: matrix.python-version == 3.11 + if: matrix.python-version == 3.12 - name: Create distribution package run: python -m build - if: matrix.python-version == 3.11 + if: matrix.python-version == 3.12 - name: Upload distribution package uses: actions/upload-artifact@master with: name: dist path: dist - if: matrix.python-version == 3.11 + if: matrix.python-version == 3.12 publish: runs-on: ubuntu-latest @@ -93,10 +94,10 @@ jobs: name: dist path: dist - - name: Use Python 3.11 + - name: Use Python 3.12 uses: actions/setup-python@v1 with: - python-version: '3.11' + python-version: '3.12' - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index d836bef..32316e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.7] - 2025-03-28 + +- Add the possibility to specify the `ActivationScope` class when instantiating + the `Container` or the `Services` object. This class will be used when + creating scopes. For the issue #55. +- Add an **experimental** class, `TrackingActivationScope` to support nested + scopes transparently, using `contextvars.ContextVar`. For more context, see + the tests `test_nested_scope_1`, `test_nested_scope_2`, + `test_nested_scope_async_1`. For the issue #55. +- Raise a `TypeError` if trying to obtain a service from a disposed scope. +- Remove Python 3.8 from the build matrix, add Python 3.13. +- Handle setuptools warning: _SetuptoolsDeprecationWarning: License classifiers are deprecated_. + ## [2.0.6] - 2023-12-09 :hammer: - Fixes import for Protocols support regardless of Python version (partially broken for Python 3.9), by @fennel-akunesh diff --git a/pyproject.toml b/pyproject.toml index e420b9f..7f3e115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,11 +7,11 @@ name = "rodi" dynamic = ["version"] authors = [{ name = "Roberto Prevato", email = "roberto.prevato@gmail.com" }] description = "Implementation of dependency injection for Python 3" +license = { file = "LICENSE" } readme = "README.md" requires-python = ">=3.7" classifiers = [ "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", ] keywords = ["dependency", "injection", "type", "hints", "typing"] diff --git a/rodi/__about__.py b/rodi/__about__.py index ff6ef86..962c851 100644 --- a/rodi/__about__.py +++ b/rodi/__about__.py @@ -1 +1 @@ -__version__ = "2.0.6" +__version__ = "2.0.7" diff --git a/rodi/__init__.py b/rodi/__init__.py index 7f27091..e1fa0a2 100644 --- a/rodi/__init__.py +++ b/rodi/__init__.py @@ -1,3 +1,4 @@ +import contextvars import inspect import re import sys @@ -259,6 +260,8 @@ def get( *, default: Optional[Any] = ..., ) -> T: + if self.provider is None: + raise TypeError("This scope is disposed.") return self.provider.get(desired_type, scope or self, default=default) def dispose(self): @@ -270,6 +273,48 @@ def dispose(self): self.scoped_services = None +class TrackingActivationScope(ActivationScope): + """ + This is an experimental class to support nested scopes transparently. + To use it, create a container including the `scope_cls` parameter: + `Container(scope_cls=TrackingActivationScope)`. + """ + + _active_scopes = contextvars.ContextVar("active_scopes", default=[]) + + __slots__ = ("scoped_services", "provider", "parent_scope") + + def __init__(self, provider=None, scoped_services=None): + # Get the current stack of active scopes + stack = self._active_scopes.get() + + # Detect the parent scope if it exists + self.parent_scope = stack[-1] if stack else None + + # Initialize scoped services + scoped_services = scoped_services or {} + if self.parent_scope: + scoped_services.update(self.parent_scope.scoped_services) + + super().__init__(provider, scoped_services) + + def __enter__(self): + # Push this scope onto the stack + stack = self._active_scopes.get() + self._active_scopes.set(stack + [self]) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # Pop this scope from the stack + stack = self._active_scopes.get() + self._active_scopes.set(stack[:-1]) + self.dispose() + + def dispose(self): + if self.provider: + self.provider = None + + class ResolutionContext: __slots__ = ("resolved", "dynamic_chain") __deletable__ = ("resolved",) @@ -679,13 +724,18 @@ class Services: Provides methods to activate instances of classes, by cached activator functions. """ - __slots__ = ("_map", "_executors") + __slots__ = ("_map", "_executors", "_scope_cls") - def __init__(self, services_map=None): + def __init__( + self, + services_map=None, + scope_cls: Optional[Type[ActivationScope]] = None, + ): if services_map is None: services_map = {} self._map = services_map self._executors = {} + self._scope_cls = scope_cls or ActivationScope def __contains__(self, item): return item in self._map @@ -696,6 +746,11 @@ def __getitem__(self, item): def __setitem__(self, key, value): self.set(key, value) + def create_scope( + self, scoped: Optional[Dict[Union[Type, str], Any]] = None + ) -> ActivationScope: + return self._scope_cls(self, scoped) + def set(self, new_type: Union[Type, str], value: Any): """ Sets a new service of desired type, as singleton. @@ -733,7 +788,7 @@ def get( :return: an instance of the desired type """ if scope is None: - scope = ActivationScope(self) + scope = self.create_scope() resolver = self._map.get(desired_type) scoped_service = scope.scoped_services.get(desired_type) if scope else None @@ -781,15 +836,15 @@ def get_executor(self, method: Callable) -> Callable: if iscoroutinefunction(method): async def async_executor( - scoped: Optional[Dict[Union[Type, str], Any]] = None + scoped: Optional[Dict[Union[Type, str], Any]] = None, ): - with ActivationScope(self, scoped) as context: + with self.create_scope(scoped) as context: return await method(*[fn(context) for fn in fns]) return async_executor def executor(scoped: Optional[Dict[Union[Type, str], Any]] = None): - with ActivationScope(self, scoped) as context: + with self.create_scope(scoped) as context: return method(*[fn(context) for fn in fns]) return executor @@ -842,13 +897,19 @@ class Container(ContainerProtocol): Configuration class for a collection of services. """ - __slots__ = ("_map", "_aliases", "_exact_aliases", "strict") + __slots__ = ("_map", "_aliases", "_exact_aliases", "_scope_cls", "strict") - def __init__(self, *, strict: bool = False): + def __init__( + self, + *, + strict: bool = False, + scope_cls: Optional[Type[ActivationScope]] = None, + ): self._map: Dict[Type, Callable] = {} self._aliases: DefaultDict[str, Set[Type]] = defaultdict(set) self._exact_aliases: Dict[str, Type] = {} self._provider: Optional[Services] = None + self._scope_cls = scope_cls self.strict = strict @property @@ -1205,7 +1266,7 @@ def build_provider(self) -> Services: for name, _type in self._exact_aliases.items(): _map[name] = self._get_alias_target_type(name, _map, _type) - return Services(_map) + return Services(_map, scope_cls=self._scope_cls) @staticmethod def _get_alias_target_type(name, _map, _type): diff --git a/tests/examples.py b/tests/examples.py index d26a0e9..4ced794 100644 --- a/tests/examples.py +++ b/tests/examples.py @@ -3,7 +3,6 @@ from typing import Optional -# domain object: class Cat: def __init__(self, name: str): self.name = name diff --git a/tests/test_fn_exec.py b/tests/test_fn_exec.py index 541e800..91ff62e 100644 --- a/tests/test_fn_exec.py +++ b/tests/test_fn_exec.py @@ -2,6 +2,7 @@ Functions exec tests. exec functions are designed to enable executing any function injecting parameters. """ + import pytest from rodi import Container, inject diff --git a/tests/test_services.py b/tests/test_services.py index 1015049..a7523fc 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,3 +1,4 @@ +import asyncio import sys from abc import ABC from dataclasses import dataclass @@ -36,6 +37,7 @@ OverridingServiceException, ServiceLifeStyle, Services, + TrackingActivationScope, UnsupportedUnionTypeException, _get_factory_annotations_or_throw, inject, @@ -2322,8 +2324,7 @@ def factory() -> annotation: def test_factory_without_locals_raises(): - def factory_without_context() -> None: - ... + def factory_without_context() -> None: ... with pytest.raises(FactoryMissingContextException): _get_factory_annotations_or_throw(factory_without_context) @@ -2331,8 +2332,7 @@ def factory_without_context() -> None: def test_factory_with_locals_get_annotations(): @inject() - def factory_without_context() -> "Cat": - ... + def factory_without_context() -> "Cat": ... annotations = _get_factory_annotations_or_throw(factory_without_context) @@ -2349,21 +2349,17 @@ def test_deps_github_scenario(): └── HTTPClient """ - class HTTPClient: - ... + class HTTPClient: ... - class CommentsService: - ... + class CommentsService: ... - class ChecksService: - ... + class ChecksService: ... class CLAHandler: comments_service: CommentsService checks_service: ChecksService - class GitHubSettings: - ... + class GitHubSettings: ... class GitHubAuthHandler: settings: GitHubSettings @@ -2477,8 +2473,7 @@ class B: def test_provide_protocol_with_attribute_dependency() -> None: class P(Protocol): - def foo(self) -> Any: - ... + def foo(self) -> Any: ... class Dependency: pass @@ -2505,8 +2500,7 @@ def foo(self) -> Any: def test_provide_protocol_with_init_dependency() -> None: class P(Protocol): - def foo(self) -> Any: - ... + def foo(self) -> Any: ... class Dependency: pass @@ -2535,11 +2529,9 @@ def test_provide_protocol_generic() -> None: T = TypeVar("T") class P(Protocol[T]): - def foo(self, t: T) -> T: - ... + def foo(self, t: T) -> T: ... - class A: - ... + class A: ... class Impl(P[A]): def foo(self, t: A) -> A: @@ -2561,11 +2553,9 @@ def test_provide_protocol_generic_with_inner_dependency() -> None: T = TypeVar("T") class P(Protocol[T]): - def foo(self, t: T) -> T: - ... + def foo(self, t: T) -> T: ... - class A: - ... + class A: ... class Dependency: pass @@ -2718,3 +2708,57 @@ class B: # check that is not being overridden by resolving a new instance assert foo is not a.foo + + +def test_nested_scope_1(): + container = Container(scope_cls=TrackingActivationScope) + container.add_scoped(Ok) + provider = container.build_provider() + + with provider.create_scope() as context_1: + a = provider.get(Ok, context_1) + + with provider.create_scope() as context_2: + b = provider.get(Ok, context_2) + + assert a is b + + +def test_nested_scope_2(): + container = Container(scope_cls=TrackingActivationScope) + container.add_scoped(Ok) + provider = container.build_provider() + + with provider.create_scope(): + with provider.create_scope() as context: + a = provider.get(Ok, context) + + with provider.create_scope() as context: + b = provider.get(Ok, context) + + assert a is not b + + +async def nested_scope_async(): + container = Container(scope_cls=TrackingActivationScope) + container.add_scoped(Ok) + provider = container.build_provider() + + with provider.create_scope() as context_1: + a = provider.get(Ok, context_1) + + await asyncio.sleep(0.01) + with provider.create_scope() as context_2: + b = provider.get(Ok, context_2) + + assert a is b + + +@pytest.mark.asyncio +async def test_nested_scope_async_1(): + await asyncio.gather( + nested_scope_async(), + nested_scope_async(), + nested_scope_async(), + nested_scope_async(), + )