diff --git a/fundi/__init__.py b/fundi/__init__.py index 35fbd0e..b211c8e 100644 --- a/fundi/__init__.py +++ b/fundi/__init__.py @@ -8,6 +8,7 @@ from .debug import tree, order from .inject import inject, ainject from .side_effects import with_side_effects +from .injection_context import InjectionContext, AsyncInjectionContext from .configurable import configurable_dependency, MutableConfigurationWarning from .virtual_context import virtual_context, VirtualContextProvider, AsyncVirtualContextProvider from .types import CallableInfo, TypeResolver, InjectionTrace, R, Parameter, DependencyConfiguration @@ -42,9 +43,11 @@ "InjectionTrace", "virtual_context", "injection_trace", + "InjectionContext", "with_side_effects", "get_configuration", "normalize_annotation", + "AsyncInjectionContext", "VirtualContextProvider", "DependencyConfiguration", "configurable_dependency", diff --git a/fundi/inject.py b/fundi/inject.py index 09ea954..4d8dea4 100644 --- a/fundi/inject.py +++ b/fundi/inject.py @@ -73,13 +73,17 @@ def injection_impl( _info = info.copy(True) _scope = {**scope} for side_effect in info.side_effects: - yield { - **scope, - "__values__": _values, - "__dependant__": _info, - "__scope__": _scope, - "__fundi_parameter__": None, - }, side_effect, True + yield ( + { + **scope, + "__values__": _values, + "__dependant__": _info, + "__scope__": _scope, + "__fundi_parameter__": None, + }, + side_effect, + True, + ) collection_logger.debug( "Passing %r with collected values %r to be called", info.call, values diff --git a/fundi/injection_context.py b/fundi/injection_context.py new file mode 100644 index 0000000..f52f847 --- /dev/null +++ b/fundi/injection_context.py @@ -0,0 +1,287 @@ +""" +Injection contexts allow you to share scope, cache, overrides and lifecycle +between multiple injections. + +Example:: + + with InjectionContext({"global_": 10}) as ctx: + ctx.inject(scan(lambda global_: print(global_))) # 10 + ctx.scope["global_"] = 20 # update context scope + + # Create sub context which will be closed automatically with parent + sub = ctx.sub() + sub.inject(scan(lambda global_: print(global_))) # 20 + + # Injection nesting + def dependant(sub: FromType[InjectionContext]): + # context passed into dependencies is the sub context of the context + # it was called with + assert sub != ctx + sub.inject(scan(another_dependant)) + + ctx.inject(scan(dependant)) + + # Create context copy, it will not be closed automatically + with ctx.copy() as copy: + inject(scan(lambda global_: print(global_))) # 20 +""" + +import typing +from types import TracebackType +from typing_extensions import Self +from contextlib import AsyncExitStack, ExitStack +from collections.abc import Mapping, MutableMapping + +from .inject import ainject, inject +from .types import CacheKey, CallableInfo + + +class InjectionContext: + """ + Synchronous injection context. + Allows only synchronous dependencies of all kinds to be injected. + """ + + def __init__( + self, + scope: Mapping[str, typing.Any] | None = None, + cache: MutableMapping[CacheKey, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + ) -> None: + self.scope: dict[str, typing.Any] = {**scope} if scope is not None else {} + + self.cache: dict[CacheKey, typing.Any] = {**cache} if cache is not None else {} + + self.override: dict[typing.Callable[..., typing.Any], typing.Any] = ( + {**override} if override is not None else {} + ) + + self.stack: ExitStack = ExitStack() + + def inject( + self, + info: CallableInfo[typing.Any], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ): + """ + Inject dependency within injection context. + This function uses scope, cache, stack and overrides defined in the context. + + Scope is modified before injection. + It is merged with provided scope via argument + and ``{'__fundi_injection_context__': self.sub()}`` + + Overrides are also merged with provided + ``override`` argument before injection. + + If ``no_cache`` is ``True`` then - cache is not used. + This includes reads and writes to cache. + """ + scope = scope or {} + override = override or {} + cache: MutableMapping[CacheKey, typing.Any] = {} if no_cache else self.cache + + return inject( + {**self.scope, **scope, "__fundi_injection_context__": self.sub()}, + info, + self.stack, + cache, + {**self.override, **override}, + ) + + def sub( + self, + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> "InjectionContext": + """ + Create copy of this injection context and + connect it to the lifecycle of this context. + + Scope is merged with provided ``scope`` argument. + + Overrides are also merged with provided + ``override`` argument. + + If ``no_cache`` is ``True`` then - cache is not copied. + """ + return self.stack.enter_context(self.copy(scope, override, no_cache)) + + def copy( + self, + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> "InjectionContext": + """ + Create copy of this injection context. + + Scope is merged with provided ``scope`` argument. + + Overrides are also merged with provided + ``override`` argument. + + If ``no_cache`` is ``True`` then - cache is not copied. + """ + scope = scope or {} + override = override or {} + cache: MutableMapping[CacheKey, typing.Any] = {} if no_cache else {**self.cache} + + return InjectionContext({**self.scope, **scope}, cache, {**self.override, **override}) + + def close(self): + """ + End lifecycle of this injection context + """ + self.stack.close() + + def __enter__(self) -> Self: + """ + Start lifecycle of this injection context. + Does nothing, as ``AsyncExitStack.__aenter__`` is empty. (CPython 3.10-3.14) + """ + self.stack.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ): + """ + End lifecycle of this injection context. + this closes all pending lifespan-dependencies. + + If context-manager is closing due to exception - + exceptions are raised inside pending dependencies. + """ + return self.stack.__exit__(exc_type, exc_value, traceback) + + +class AsyncInjectionContext: + """ + Synchronous injection context. + Allows both synchronous and asynchronous dependencies of all kinds to be injected. + """ + + def __init__( + self, + scope: Mapping[str, typing.Any] | None = None, + cache: MutableMapping[CacheKey, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + ) -> None: + self.scope: dict[str, typing.Any] = {**scope} if scope is not None else {} + + self.cache: dict[CacheKey, typing.Any] = {**cache} if cache is not None else {} + + self.override: dict[typing.Callable[..., typing.Any], typing.Any] = ( + {**override} if override is not None else {} + ) + + self.stack: AsyncExitStack = AsyncExitStack() + + async def inject( + self, + info: CallableInfo[typing.Any], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ): + """ + Inject dependency within injection context. + This function uses scope, cache, stack and overrides defined in the context. + + Scope is modified before injection. + It is merged with provided scope via argument + and ``{'__fundi_injection_context__': self.sub()}`` + + Overrides are also merged with provided + ``override`` argument before injection. + + If ``no_cache`` is ``True`` then - cache is not used. + This includes reads and writes to cache. + """ + scope = scope or {} + override = override or {} + cache: MutableMapping[CacheKey, typing.Any] = {} if no_cache else self.cache + return await ainject( + {**self.scope, **scope, "__fundi_injection_context__": await self.sub()}, + info, + self.stack, + cache, + {**self.override, **override}, + ) + + async def sub( + self, + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> "AsyncInjectionContext": + """ + Create copy of this injection context and + connect it to the lifecycle of this context. + + Scope is merged with provided ``scope`` argument. + + Overrides are also merged with provided + ``override`` argument. + + If ``no_cache`` is ``True`` then - cache is not copied. + """ + return await self.stack.enter_async_context(self.copy(scope, override, no_cache)) + + def copy( + self, + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> "AsyncInjectionContext": + """ + Create copy of this injection context. + + Scope is merged with provided ``scope`` argument. + + Overrides are also merged with provided + ``override`` argument. + + If ``no_cache`` is ``True`` then - cache is not copied. + """ + scope = scope or {} + override = override or {} + cache: MutableMapping[CacheKey, typing.Any] = {} if no_cache else {**self.cache} + + return AsyncInjectionContext({**self.scope, **scope}, cache, {**self.override, **override}) + + async def close(self) -> None: + """ + End lifecycle of this injection context + """ + await self.stack.aclose() + + async def __aenter__(self) -> Self: + """ + Start lifecycle of this injection context. + Does nothing, as ``AsyncExitStack.__aenter__`` is empty. (CPython 3.10-3.14) + """ + await self.stack.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + """ + End lifecycle of this injection context. + this closes all pending lifespan-dependencies. + + If context-manager is closing due to exception - + exceptions are raised inside pending dependencies. + """ + return await self.stack.__aexit__(exc_type, exc_value, traceback) diff --git a/fundi/injection_context.pyi b/fundi/injection_context.pyi new file mode 100644 index 0000000..2246a6b --- /dev/null +++ b/fundi/injection_context.pyi @@ -0,0 +1,169 @@ +import typing +from types import TracebackType +from typing_extensions import Self, overload +from collections.abc import Mapping, MutableMapping, Generator, AsyncGenerator, Awaitable + +from .types import CacheKey, CallableInfo + +from contextlib import ( + ExitStack, + AsyncExitStack, + AbstractContextManager, + AbstractAsyncContextManager, +) + +R = typing.TypeVar("R") + +class InjectionContext: + scope: dict[str, typing.Any] + cache: dict[CacheKey, typing.Any] + override: dict[typing.Callable[..., typing.Any], typing.Any] + stack: ExitStack + + def __init__( + self, + scope: Mapping[str, typing.Any] | None = None, + cache: MutableMapping[CacheKey, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + ) -> None: + self.scope: dict[str, typing.Any] = {**scope} if scope is not None else {} + + self.cache: dict[CacheKey, typing.Any] = {**cache} if cache is not None else {} + + self.override: dict[typing.Callable[..., typing.Any], typing.Any] = ( + {**override} if override is not None else {} + ) + + self.stack: ExitStack = ExitStack() + + def sub( + self, + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> "InjectionContext": ... + def copy( + self, + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> "InjectionContext": ... + def close(self) -> None: ... + def __enter__(self) -> Self: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: ... + @overload + def inject( + self, + info: CallableInfo[Generator[R, None, None]], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + def inject( + self, + info: CallableInfo[AbstractContextManager[R]], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + def inject( + self, + info: CallableInfo[R], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + def inject( + self, + info: CallableInfo[typing.Any], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ): ... + +class AsyncInjectionContext: + scope: dict[str, typing.Any] + cache: dict[CacheKey, typing.Any] + override: dict[typing.Callable[..., typing.Any], typing.Any] + stack: AsyncExitStack + + def __init__( + self, + scope: Mapping[str, typing.Any] | None = None, + cache: MutableMapping[CacheKey, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + ) -> None: ... + async def sub( + self, + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> "AsyncInjectionContext": ... + def copy( + self, + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> "AsyncInjectionContext": ... + async def close(self) -> None: ... + async def __aenter__(self) -> Self: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: ... + @overload + async def inject( + self, + info: CallableInfo[Generator[R, None, None]], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + async def inject( + self, + info: CallableInfo[AsyncGenerator[R, None]], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + async def inject( + self, + info: CallableInfo[Awaitable[R]], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + async def inject( + self, + info: CallableInfo[AbstractAsyncContextManager[R]], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + async def inject( + self, + info: CallableInfo[AbstractContextManager[R]], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + async def inject( + self, + info: CallableInfo[R], + scope: Mapping[str, typing.Any] | None = None, + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... diff --git a/pyproject.toml b/pyproject.toml index 2cbb0dd..6b296ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,3 +52,11 @@ ignore = ["tests"] [tool.black] line-length=100 +[tool.ty.src] +respect-ignore-files = false + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint.isort] +length-sort = true diff --git a/tests/inject/test_async_injection_context.py b/tests/inject/test_async_injection_context.py new file mode 100644 index 0000000..35fa7f0 --- /dev/null +++ b/tests/inject/test_async_injection_context.py @@ -0,0 +1,103 @@ +from fundi import scan, from_, AsyncInjectionContext + + +async def test_async_scope_sharing(): + async with AsyncInjectionContext({"scope_value": 1}) as ctx: + injections = 0 + + async def dep(scope_value: int): + nonlocal injections + injections += 1 + + assert scope_value == 1 + + await ctx.inject(scan(dep)) + await ctx.inject(scan(dep)) + + assert injections == 2 + + +async def test_async_cache_sharing(): + async with AsyncInjectionContext() as ctx: + injections = 0 + dep_calls = 0 + + async def dep(): + nonlocal dep_calls + dep_calls += 1 + + async def dependant(value: None = from_(dep)): + nonlocal injections + injections += 1 + + await ctx.inject(scan(dependant)) + await ctx.inject(scan(dependant)) + + assert dep_calls == 1 + assert injections == 2 + + +async def test_async_override_sharing(): + dep_calls = 0 + + async def dep(): + nonlocal dep_calls + dep_calls += 1 + + async with AsyncInjectionContext(override={dep: 0}) as ctx: + injections = 0 + + async def dependant(value: None = from_(dep)): + nonlocal injections + injections += 1 + + await ctx.inject(scan(dependant)) + await ctx.inject(scan(dependant)) + + assert dep_calls == 0 + assert injections == 2 + + +async def test_async_lifecycle_sharing(): + async with AsyncInjectionContext({"scope_value": 1}) as ctx: + enters = 0 + exits = 0 + + async def dep(scope_value: int): + nonlocal enters, exits + enters += 1 + yield + exits += 1 + + await ctx.inject(scan(dep)) + assert enters == 1 + assert exits == 0 + + await ctx.inject(scan(dep)) + assert enters == 2 + assert exits == 0 + + assert exits == 2 + + +async def test_async_nested_lifecycle_sharing(): + async with AsyncInjectionContext({"scope_value": 1}) as ctx: + enters = 0 + exits = 0 + + def dep(scope_value: int): + nonlocal enters, exits + enters += 1 + yield + exits += 1 + + await ctx.inject(scan(dep)) + assert enters == 1 + assert exits == 0 + + sub = await ctx.sub() + await sub.inject(scan(dep)) + assert enters == 2 + assert exits == 0 + + assert exits == 2 diff --git a/tests/inject/test_injection_context.py b/tests/inject/test_injection_context.py new file mode 100644 index 0000000..7fc8ffd --- /dev/null +++ b/tests/inject/test_injection_context.py @@ -0,0 +1,103 @@ +from fundi import scan, from_, InjectionContext + + +def test_sync_scope_sharing(): + with InjectionContext({"scope_value": 1}) as ctx: + injections = 0 + + def dep(scope_value: int): + nonlocal injections + injections += 1 + + assert scope_value == 1 + + ctx.inject(scan(dep)) + ctx.inject(scan(dep)) + + assert injections == 2 + + +def test_sync_cache_sharing(): + with InjectionContext() as ctx: + injections = 0 + dep_calls = 0 + + def dep(): + nonlocal dep_calls + dep_calls += 1 + + def dependant(value: None = from_(dep)): + nonlocal injections + injections += 1 + + ctx.inject(scan(dependant)) + ctx.inject(scan(dependant)) + + assert dep_calls == 1 + assert injections == 2 + + +def test_sync_override_sharing(): + dep_calls = 0 + + def dep(): + nonlocal dep_calls + dep_calls += 1 + + with InjectionContext(override={dep: 0}) as ctx: + injections = 0 + + def dependant(value: None = from_(dep)): + nonlocal injections + injections += 1 + + ctx.inject(scan(dependant)) + ctx.inject(scan(dependant)) + + assert dep_calls == 0 + assert injections == 2 + + +def test_sync_lifecycle_sharing(): + with InjectionContext({"scope_value": 1}) as ctx: + enters = 0 + exits = 0 + + def dep(scope_value: int): + nonlocal enters, exits + enters += 1 + yield + exits += 1 + + ctx.inject(scan(dep)) + assert enters == 1 + assert exits == 0 + + ctx.inject(scan(dep)) + assert enters == 2 + assert exits == 0 + + assert exits == 2 + + +def test_sync_nested_lifecycle_sharing(): + with InjectionContext({"scope_value": 1}) as ctx: + enters = 0 + exits = 0 + + def dep(scope_value: int): + nonlocal enters, exits + enters += 1 + yield + exits += 1 + + ctx.inject(scan(dep)) + assert enters == 1 + assert exits == 0 + + sub = ctx.sub() + sub.inject(scan(dep)) + assert enters == 2 + assert exits == 0 + + assert exits == 2