From 2279f40eb93de3e14afe7e4f8e0417fcd0bf6033 Mon Sep 17 00:00:00 2001 From: raceychan Date: Mon, 3 Feb 2025 15:52:57 +0800 Subject: [PATCH] Release version 1.3.6 --- CHANGELOG.md | 10 +- README.md | 23 +++- ididi/__init__.py | 8 +- ididi/_ds.py | 4 +- ididi/graph.py | 124 ++++++++++++------- ididi/interfaces.py | 2 + ididi/utils/typing_utils.py | 5 +- tests/features/test_context_scope.py | 32 ----- tests/regression/test_graph_scope_resolve.py | 8 +- tests/regression/test_slots.py | 39 ++---- tests/test_scope.py | 56 ++++++++- 11 files changed, 187 insertions(+), 124 deletions(-) delete mode 100644 tests/features/test_context_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 517bb23..c62a6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -949,8 +949,16 @@ and such override won't affect others. ## version 1.3.5 -a quick bug fix, where in 1.3.4, registered_singleton is not shared between graph and scope. +- a quick bug fix, where in 1.3.4, registered_singleton is not shared between graph and scope. The general rule is that scope can access registered singletons and resolved instances but not vice versa. + +## version 1.3.6 + + +- Fix: `Graph.entry` no longer uses existing scope, instead, always create a new scope + + + diff --git a/README.md b/README.md index 9452447..a0c7e11 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,17 @@ async def main(command: CreateUser, uow: UnitOfWork): await main(CreateUser(name='user')) ``` +To resolve `AsyncConnection` outside of entry function + +```python +from ididi import Graph + +dg = Graph() + +async with dg.scope(): + conn = await scope.resolve(conn_factory) +``` + ### Dependency factory @@ -108,7 +119,17 @@ Check out `tests/features/test_typing_support.py` for examples. ### Scope -Using Scope to manage resources +`Scope` is a temporary view of the graph specialized for handling resources. + +In a nutshell: + +- Scope can access registered singletons and resolved instances of its parent graph + +- its parent graph can't access its registered singletons and resolved resources. + +- Non-resource instances resolved by the scope can be access by its parent graph + +#### Using Scope to manage resources - **Infinite number of nested scope** - **Parent scope can be accssed by its child scopes(within the same context)** diff --git a/ididi/__init__.py b/ididi/__init__.py index 3583ddc..d21a391 100644 --- a/ididi/__init__.py +++ b/ididi/__init__.py @@ -14,7 +14,7 @@ license: MIT, see LICENSE for more details. """ -VERSION = "1.3.5" +VERSION = "1.3.6" __version__ = VERSION @@ -25,13 +25,13 @@ from .api import entry as entry from .api import resolve as resolve from .graph import AsyncScope as AsyncScope +from .graph import DependencyGraph as DependencyGraph from .graph import Graph as Graph from .graph import SyncScope as SyncScope -from .graph import DependencyGraph as DependencyGraph +from .interfaces import AsyncResource as AsyncResource from .interfaces import INode as INode from .interfaces import INodeConfig as INodeConfig -from .utils.typing_utils import AsyncResource as AsyncResource -from .utils.typing_utils import Resource as Resource +from .interfaces import Resource as Resource try: import graphviz as graphviz # type: ignore diff --git a/ididi/_ds.py b/ididi/_ds.py index 1fd18a4..584ba83 100644 --- a/ididi/_ds.py +++ b/ididi/_ds.py @@ -16,9 +16,9 @@ ### a readonly view of GraphNodes """ -ResolvedInstances = dict[type[T], T] +ResolvedSingletons = dict[type[T], T] """ -mapping a type to its resolved instance +mapping a type to its resolved instance, only instances of reusable node will be added here. """ TypeMappings = dict[type[T], list[type[T]]] diff --git a/ididi/graph.py b/ididi/graph.py index 72b577a..904f93d 100644 --- a/ididi/graph.py +++ b/ididi/graph.py @@ -25,7 +25,7 @@ from typing_extensions import Self, Unpack, override -from ._ds import GraphNodes, GraphNodesView, ResolvedInstances, TypeRegistry, Visitor +from ._ds import GraphNodes, GraphNodesView, ResolvedSingletons, TypeRegistry, Visitor from ._node import ( DefaultConfig, Dependencies, @@ -109,7 +109,7 @@ class SharedData(TypedDict): "_resolved_nodes", "_type_registry", "_ignore", - "_resolved_instances", + "_resolved_singletons", "_registered_singletons", ) @@ -119,7 +119,7 @@ class Resolver: def __init__( self, - resolved_instances: ResolvedInstances[Any], + resolved_singletons: ResolvedSingletons[Any], registered_singletons: set[type], **args: Unpack[SharedData], ): @@ -129,7 +129,7 @@ def __init__( self._ignore = args["ignore"] self._registered_singletons = registered_singletons - self._resolved_instances = resolved_instances + self._resolved_singletons = resolved_singletons def is_registered_singleton(self, dependent_type: type) -> bool: return dependent_type in self._registered_singletons @@ -160,7 +160,7 @@ def _remove_node(self, node: DependentNode[Any]) -> None: self._nodes.pop(dependent_type) self._type_registry.remove(dependent_type) - self._resolved_instances.pop(dependent_type, None) + self._resolved_singletons.pop(dependent_type, None) self._resolved_nodes.pop(dependent_type, None) def _register_node(self, node: DependentNode[Any]) -> None: @@ -188,7 +188,7 @@ def _resolve_concrete_node(self, dependent: type[T]) -> DependentNode[Any]: return concrete_node def get_resolve_cache(self, dependent: IFactory[P, T]) -> Maybe[T]: - return self._resolved_instances.get(dependent, MISSING) + return self._resolved_singletons.get(dependent, MISSING) def resolve_callback( self, @@ -201,7 +201,7 @@ def resolve_callback( raise ResourceOutsideScopeError(dependent_type) if is_reuse: - register_dependent(self._resolved_instances, dependent_type, resolved) + register_dependent(self._resolved_singletons, dependent_type, resolved) return resolved async def aresolve_callback( @@ -216,7 +216,7 @@ async def aresolve_callback( instance = await resolved if isawaitable(resolved) else resolved if is_reuse: - register_dependent(self._resolved_instances, dependent_type, instance) + register_dependent(self._resolved_singletons, dependent_type, instance) return cast(T, instance) @overload @@ -514,7 +514,7 @@ async def main(command: CreateUser, uow: UnitOfWork): "Nodes that have been recursively resolved and is validated to be resolvable." _type_registry: TypeRegistry "Map a type to its implementations" - _resolved_instances: ResolvedInstances[Any] + _resolved_singletons: ResolvedSingletons[Any] "Instances of reusable types" _registered_singletons: set[type] "Types that are menually added singletons " @@ -552,7 +552,7 @@ def __init__( nodes=dict(), resolved_nodes=dict(), type_registry=TypeRegistry(), - resolved_instances=dict(), + resolved_singletons=dict(), registered_singletons=set(), ignore=config.ignore, ) @@ -564,7 +564,7 @@ def __repr__(self) -> str: return ( f"{self.__class__.__name__}(" f"nodes={len(self._nodes)}, " - f"resolved={len(self._resolved_instances)})" + f"resolved={len(self._resolved_singletons)})" ) def __contains__(self, item: INode[P, T]) -> bool: @@ -575,11 +575,11 @@ def nodes(self) -> GraphNodesView[Any]: return MappingProxyType(self._nodes) @property - def resolution_registry(self) -> ResolvedInstances[Any]: + def resolution_registry(self) -> ResolvedSingletons[Any]: """ A mapping of dependent types to their resolved instances. """ - return self._resolved_instances + return self._resolved_singletons @property def type_registry(self) -> TypeRegistry: @@ -650,7 +650,7 @@ def merge(self, other: Union["Graph", Sequence["Graph"]]): self._merge_nodes(other) self._type_registry.update(other._type_registry) - self._resolved_instances.update(other._resolved_instances) + self._resolved_singletons.update(other._resolved_singletons) def reset(self, clear_nodes: bool = False) -> None: """ @@ -662,7 +662,7 @@ def reset(self, clear_nodes: bool = False) -> None: - the node registry - the type registry """ - self._resolved_instances.clear() + self._resolved_singletons.clear() self._registered_singletons.clear() if clear_nodes: @@ -678,7 +678,7 @@ def register_singleton( """ if dependent_type is None: dependent_type = type(dependent) - register_dependent(self._resolved_instances, dependent_type, dependent) + register_dependent(self._resolved_singletons, dependent_type, dependent) self._registered_singletons.add(dependent_type) def remove_singleton(self, dependent_type: type) -> None: @@ -720,11 +720,16 @@ def scope(self, name: Maybe[Hashable] = MISSING) -> "ScopeManager": type_registry=self._type_registry, ignore=self._ignore, ) + """ + # isolated: bool = False + if True, + don't share resolved_singletons and registered_singleton + """ return ScopeManager( name=name, scope_ctx=self._scope_context, - graph_resolutions=self._resolved_instances, + graph_resolutions=self._resolved_singletons, graph_singletons=self._registered_singletons, shared_data=shared_data, ) @@ -873,7 +878,7 @@ def replace( @wraps(func) async def _async_scoped_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: - context_scope = self.use_scope(create_on_miss=True, as_async=True) + context_scope = self.scope() async with context_scope as scope: for param_name, param_type in unresolved: if param_name in kwargs: @@ -902,7 +907,7 @@ async def _async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: @wraps(sync_func) def _sync_scoped_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: - context_scope = self.use_scope(create_on_miss=True, as_async=False) + context_scope = self.scope() with context_scope as scope: for param_name, param_type in unresolved: if param_name in kwargs: @@ -939,7 +944,7 @@ class ScopeBase(Generic[Stack]): _name: Hashable _pre: Maybe[Union["SyncScope", "AsyncScope"]] - _resolved_instances: ResolvedInstances[Any] + _resolved_singletons: ResolvedSingletons[Any] _registered_singletons: set[type] def __repr__(self): @@ -970,7 +975,7 @@ def register_singleton( if dependent_type is None: dependent_type = type(dependent) - register_dependent(self._resolved_instances, dependent_type, dependent) + register_dependent(self._resolved_singletons, dependent_type, dependent) self._registered_singletons.add(dependent_type) @@ -980,19 +985,24 @@ class SyncScope(ScopeBase[ExitStack], Resolver): def __init__( self, *, - graph_resolutions: ResolvedInstances[Any], + graph_resolutions: ResolvedSingletons[Any], graph_singletons: set[type], + registered_singletons: set[type], + resolved_singletons: ResolvedSingletons[Any], name: Maybe[Hashable] = MISSING, pre: Maybe[Union["SyncScope", "AsyncScope"]] = MISSING, **args: Unpack[SharedData], ): - self._name = name self._pre = pre self._stack = ExitStack() self._graph_resolutions = graph_resolutions self._graph_singletons = graph_singletons - super().__init__(**args, registered_singletons=set(), resolved_instances=dict()) + super().__init__( + **args, + registered_singletons=registered_singletons, + resolved_singletons=resolved_singletons, + ) def __enter__(self) -> "SyncScope": return self @@ -1023,12 +1033,12 @@ def resolve_callback( instance = self.enter_context(cast(ContextManager[T], resolved)) if is_reuse: - register_dependent(self._resolved_instances, dependent_type, instance) + register_dependent(self._resolved_singletons, dependent_type, instance) return instance @override def get_resolve_cache(self, dependent: IFactory[P, T]) -> Maybe[T]: - if resolution := self._resolved_instances.get(dependent, MISSING): + if resolution := self._resolved_singletons.get(dependent, MISSING): return resolution if self.should_be_scoped(dependent): @@ -1043,8 +1053,10 @@ class AsyncScope(ScopeBase[AsyncExitStack], Resolver): def __init__( self, *, - graph_resolutions: ResolvedInstances[Any], + graph_resolutions: ResolvedSingletons[Any], graph_singletons: set[type], + registered_singletons: set[type], + resolved_singletons: ResolvedSingletons[Any], name: Maybe[Hashable] = MISSING, pre: Maybe[Union["SyncScope", "AsyncScope"]] = MISSING, **args: Unpack[SharedData], @@ -1055,7 +1067,11 @@ def __init__( self._graph_resolutions = graph_resolutions self._graph_singletons = graph_singletons - super().__init__(**args, registered_singletons=set(), resolved_instances=dict()) + super().__init__( + **args, + registered_singletons=registered_singletons, + resolved_singletons=resolved_singletons, + ) async def __aenter__(self) -> "AsyncScope": return self @@ -1070,7 +1086,7 @@ async def __aexit__( @override def get_resolve_cache(self, dependent: IFactory[P, T]) -> Maybe[T]: - if resolution := self._resolved_instances.get(dependent, MISSING): + if resolution := self._resolved_singletons.get(dependent, MISSING): return resolution if self.should_be_scoped(dependent): @@ -1116,7 +1132,7 @@ async def aresolve_callback( ) if is_reuse: - register_dependent(self._resolved_instances, dependent_type, instance) + register_dependent(self._resolved_singletons, dependent_type, instance) return instance @@ -1138,7 +1154,7 @@ def __init__( self, shared_data: SharedData, scope_ctx: ScopeContext, - graph_resolutions: ResolvedInstances[Any], + graph_resolutions: ResolvedSingletons[Any], graph_singletons: set[type], name: Maybe[Hashable] = MISSING, ): @@ -1151,19 +1167,41 @@ def __init__( self._scope: Maybe[Union[SyncScope, AsyncScope]] = MISSING self._token: Maybe[ScopeToken] = MISSING + def create_scope(self, previous_scope: Maybe[Union[SyncScope, AsyncScope]]): + """ + in 1.3.6, merge registered_singletons and resolved_singletons with previous + + resolved_singletons = prev._resolved_singletons + """ + + return SyncScope( + graph_resolutions=self.graph_resolutions, + graph_singletons=self.graph_singletons, + registered_singletons=set(), + resolved_singletons=dict(), + name=self.name, + pre=previous_scope, + **self.shared_data, + ) + + def create_ascope(self, previous_scope: Maybe[Union[SyncScope, AsyncScope]]): + return AsyncScope( + graph_resolutions=self.graph_resolutions, + graph_singletons=self.graph_singletons, + registered_singletons=set(), + resolved_singletons=dict(), + name=self.name, + pre=previous_scope, + **self.shared_data, + ) + def __enter__(self) -> SyncScope: try: pre = self.scope_ctx.get() except LookupError: pre = MISSING - self._scope = SyncScope( - graph_resolutions=self.graph_resolutions, - graph_singletons=self.graph_singletons, - name=self.name, - pre=pre, - **self.shared_data, - ).__enter__() + self._scope = self.create_scope(previous_scope=pre) self._token = self.scope_ctx.set(self._scope) return self._scope @@ -1173,14 +1211,7 @@ async def __aenter__(self) -> AsyncScope: except LookupError: pre = MISSING - self._scope = await AsyncScope( - graph_resolutions=self.graph_resolutions, - graph_singletons=self.graph_singletons, - name=self.name, - pre=pre, - **self.shared_data, - ).__aenter__() - + self._scope = self.create_ascope(previous_scope=pre) self._token = self.scope_ctx.set(self._scope) return self._scope @@ -1201,7 +1232,6 @@ async def __aexit__( traceback: Union[TracebackType, None], ) -> None: await cast(AsyncScope, self._scope).__aexit__(exc_type, exc_value, traceback) - if is_provided(self._token): self.scope_ctx.reset(self._token) diff --git a/ididi/interfaces.py b/ididi/interfaces.py index a8d072f..a0fac28 100644 --- a/ididi/interfaces.py +++ b/ididi/interfaces.py @@ -54,6 +54,8 @@ NodeIgnoreConfig = Union[NodeConfigParam, Iterable[NodeConfigParam]] GraphIgnoreConfig = Union[GraphConfigParam, Iterable[GraphConfigParam]] +Resource = Generator[T, None, None] +AsyncResource = AsyncGenerator[T, None] # P1 = TypeVar("P1") # P2 = TypeVar("P2") diff --git a/ididi/utils/typing_utils.py b/ididi/utils/typing_utils.py index 6c83a54..9cfd013 100644 --- a/ididi/utils/typing_utils.py +++ b/ididi/utils/typing_utils.py @@ -1,4 +1,4 @@ -from typing import Any, AsyncGenerator, ForwardRef, Generator, Mapping, TypeVar, Union +from typing import Any, ForwardRef, Mapping, TypeVar, Union from typing import _eval_type as ty_eval_type # type: ignore from typing import cast @@ -9,8 +9,7 @@ P = ParamSpec("P") C = TypeVar("C", covariant=True) -Resource = Generator[T, None, None] -AsyncResource = AsyncGenerator[T, None] + PrimitiveBuiltins = type[Union[int, float, complex, str, bool, bytes, bytearray]] ContainerBuiltins = type[ diff --git a/tests/features/test_context_scope.py b/tests/features/test_context_scope.py deleted file mode 100644 index 3cc37b8..0000000 --- a/tests/features/test_context_scope.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest - -from ididi import DependencyGraph - -from ididi.errors import OutOfScopeError - -@pytest.mark.asyncio -async def test_scope_different_across_context(): - dg = DependencyGraph() - - class Normal: - def __init__(self, name: str = "normal"): - self.name = name - - with dg.scope(1) as s1: - - def func1(): - with dg.scope(2) as s2: - with dg.scope(3) as s3: - repr(s1) - s3.get_scope(1) - s3.get_scope(2) - s3.get_scope(3) - return s1 - - func1() - - def func2(): - dg.use_scope(2) - - with pytest.raises(OutOfScopeError): - func2() diff --git a/tests/regression/test_graph_scope_resolve.py b/tests/regression/test_graph_scope_resolve.py index 7c85d51..835e173 100644 --- a/tests/regression/test_graph_scope_resolve.py +++ b/tests/regression/test_graph_scope_resolve.py @@ -13,8 +13,8 @@ def test_scope_resolve_fallback(): with graph.scope() as scope: u2 = scope.resolve(UserService) - assert UserService in graph._resolved_instances - assert UserService not in scope._resolved_instances + assert UserService in graph._resolved_singletons + assert UserService not in scope._resolved_singletons assert u is u2 @@ -32,8 +32,8 @@ def user_factory(dg: Graph) -> Generator[UserService, None, None]: with graph.scope() as scope: u2 = scope.resolve(UserService) - assert UserService not in graph._resolved_instances - assert UserService in scope._resolved_instances + assert UserService not in graph._resolved_singletons + assert UserService in scope._resolved_singletons assert u is not u2 diff --git a/tests/regression/test_slots.py b/tests/regression/test_slots.py index 579b07b..1a1ec32 100644 --- a/tests/regression/test_slots.py +++ b/tests/regression/test_slots.py @@ -1,45 +1,26 @@ import pytest from ididi._node import Dependencies, Dependency, DependentNode -from ididi.graph import AsyncScope, Graph, ScopeManager, SyncScope +from ididi.graph import Graph -def test_graph_ds_slots(): +async def test_graph_ds_slots(): dg = Graph() - sm = ScopeManager(1, 2, 3, 4) - asc = AsyncScope( - graph_resolutions=1, - graph_singletons=2, - name=2, - pre=3, - nodes=4, - resolved_nodes=5, - type_registry=6, - ignore=7, - ) - - sc = SyncScope( - graph_resolutions=1, - graph_singletons=9, - name=2, - pre=3, - nodes=4, - resolved_nodes=5, - type_registry=6, - ignore=7, - ) - with pytest.raises(AttributeError): dg.__dict__ + sm = dg.scope() + with pytest.raises(AttributeError): sm.__dict__ - with pytest.raises(AttributeError): - asc.__dict__ + async with sm as asc: + with pytest.raises(AttributeError): + asc.__dict__ - with pytest.raises(AttributeError): - sc.__dict__ + with sm as sc: + with pytest.raises(AttributeError): + sc.__dict__ def test_node_ds_slots(): diff --git a/tests/test_scope.py b/tests/test_scope.py index 125a482..acb03e7 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -2,7 +2,7 @@ import pytest -from ididi import DependencyGraph +from ididi import AsyncResource, DependencyGraph from ididi.errors import ( AsyncResourceInSyncError, OutOfScopeError, @@ -405,3 +405,57 @@ async def main(db: AsyncDataBase, sql: str) -> ty.Any: sql = "select moeny from bank" assert await main(sql=sql) == sql + + +@pytest.mark.skip("not implemented") +async def test_scope_stack(): + dg = DependencyGraph() + + class Connection: ... + + async def conn_factory() -> AsyncResource[Connection]: + conn = Connection() + yield conn + + async with dg.scope() as parent_scope: + conn = await parent_scope.resolve(conn_factory) + async with dg.scope() as sub_scope: + sub_conn = await sub_scope.resolve(conn_factory) + assert sub_conn is conn + + +@pytest.mark.asyncio +async def test_scope_different_across_context(): + dg = DependencyGraph() + + class Normal: + def __init__(self, name: str = "normal"): + self.name = name + + with dg.scope(1) as s1: + + def func1(): + with dg.scope(2) as s2: + with dg.scope(3) as s3: + repr(s1) + n3 = s3.resolve(Normal) + n1 = s1.resolve(Normal) + assert ( + n1 is n3 + ), "Non-resources reusable instances should be shared across scopes" + assert s3.get_scope(1) is s1 + assert s3.get_scope(2) is s2 + assert s3.get_scope(3) is s3 + + with pytest.raises(OutOfScopeError): + s3 = dg.use_scope(3) + + return s1 + + func1() + + def func2(): + dg.use_scope(2) + + with pytest.raises(OutOfScopeError): + func2()