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 .flake8
Original file line number Diff line number Diff line change
@@ -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
13 changes: 7 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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: |
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,19 @@ 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",
"Programming Language :: Python :: 3.9",
"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"]
Expand Down
2 changes: 1 addition & 1 deletion rodi/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.0.6"
__version__ = "2.0.7"
79 changes: 70 additions & 9 deletions rodi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextvars
import inspect
import re
import sys
Expand Down Expand Up @@ -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):
Expand All @@ -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",)
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
1 change: 0 additions & 1 deletion tests/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from typing import Optional


# domain object:
class Cat:
def __init__(self, name: str):
self.name = name
Expand Down
1 change: 1 addition & 0 deletions tests/test_fn_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading