From f4079f285d35637b0f656f1d230e53d4b202660f Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Wed, 26 Nov 2025 23:17:48 +0200 Subject: [PATCH 1/6] Implement [Async]InjectionContext --- fundi/injection_context.py | 180 +++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 fundi/injection_context.py diff --git a/fundi/injection_context.py b/fundi/injection_context.py new file mode 100644 index 0000000..16905a0 --- /dev/null +++ b/fundi/injection_context.py @@ -0,0 +1,180 @@ +""" +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 contextlib import AsyncExitStack, ExitStack +from collections.abc import Mapping, MutableMapping + +from fundi.types import CacheKey +from fundi import CallableInfo, ainject, inject + + +class InjectionContext: + 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, + ): + 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, + ): + + 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, + ): + 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): + self.stack.close() + + def __enter__(self): + self.stack.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ): + return self.stack.__exit__(exc_type, exc_value, traceback) + + +class AsyncInjectionContext: + 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, + ): + 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, + ): + + 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, + ): + 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): + await self.stack.aclose() + + async def __aenter__(self): + await self.stack.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ): + return await self.stack.__aexit__(exc_type, exc_value, traceback) From 49925a9211155b475d89b80e920732a3253e5194 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sun, 30 Nov 2025 00:47:12 +0200 Subject: [PATCH 2/6] Add injection context tests --- fundi/__init__.py | 3 + fundi/injection_context.py | 44 +++---- tests/inject/test_injection_context.py | 159 +++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 tests/inject/test_injection_context.py 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/injection_context.py b/fundi/injection_context.py index 16905a0..f1e942b 100644 --- a/fundi/injection_context.py +++ b/fundi/injection_context.py @@ -4,26 +4,26 @@ 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 + 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 @@ -31,8 +31,8 @@ def dependant(sub: FromType[InjectionContext]): from contextlib import AsyncExitStack, ExitStack from collections.abc import Mapping, MutableMapping -from fundi.types import CacheKey -from fundi import CallableInfo, ainject, inject +from .inject import ainject, inject +from .types import CacheKey, CallableInfo class InjectionContext: diff --git a/tests/inject/test_injection_context.py b/tests/inject/test_injection_context.py new file mode 100644 index 0000000..df57599 --- /dev/null +++ b/tests/inject/test_injection_context.py @@ -0,0 +1,159 @@ +from fundi import scan, from_, InjectionContext, AsyncInjectionContext + + +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 + + +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 From f0c1b479af862b58c89f68f8ed77d88cfb84889c Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sun, 30 Nov 2025 00:53:35 +0200 Subject: [PATCH 3/6] Split tests into async and sync modules; add testing nested lifecycle --- tests/inject/test_async_injection_context.py | 103 +++++++++++++++++++ tests/inject/test_injection_context.py | 70 ++----------- 2 files changed, 110 insertions(+), 63 deletions(-) create mode 100644 tests/inject/test_async_injection_context.py 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 index df57599..7fc8ffd 100644 --- a/tests/inject/test_injection_context.py +++ b/tests/inject/test_injection_context.py @@ -1,4 +1,4 @@ -from fundi import scan, from_, InjectionContext, AsyncInjectionContext +from fundi import scan, from_, InjectionContext def test_sync_scope_sharing(): @@ -80,79 +80,23 @@ def dep(scope_value: int): assert exits == 2 -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: +def test_sync_nested_lifecycle_sharing(): + with InjectionContext({"scope_value": 1}) as ctx: enters = 0 exits = 0 - async def dep(scope_value: int): + def dep(scope_value: int): nonlocal enters, exits enters += 1 yield exits += 1 - await ctx.inject(scan(dep)) + ctx.inject(scan(dep)) assert enters == 1 assert exits == 0 - await ctx.inject(scan(dep)) + sub = ctx.sub() + sub.inject(scan(dep)) assert enters == 2 assert exits == 0 From 0396e7ecf24b4673e58d62c61eb8272f6f9b9fc8 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Fri, 19 Dec 2025 16:20:58 +0200 Subject: [PATCH 4/6] Provide stub-file for InjectionContext and AsyncInjectionContext --- fundi/inject.py | 18 ++-- fundi/injection_context.py | 18 ++-- fundi/injection_context.pyi | 163 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 8 ++ 4 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 fundi/injection_context.pyi 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 index f1e942b..7b84dd4 100644 --- a/fundi/injection_context.py +++ b/fundi/injection_context.py @@ -28,6 +28,7 @@ def dependant(sub: FromType[InjectionContext]): import typing from types import TracebackType +from typing_extensions import Self from contextlib import AsyncExitStack, ExitStack from collections.abc import Mapping, MutableMapping @@ -76,7 +77,7 @@ def sub( scope: Mapping[str, typing.Any] | None = None, override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, no_cache: bool = False, - ): + ) -> "InjectionContext": return self.stack.enter_context(self.copy(scope, override, no_cache)) @@ -85,7 +86,7 @@ def copy( scope: Mapping[str, typing.Any] | None = None, override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, no_cache: bool = False, - ): + ) -> "InjectionContext": scope = scope or {} override = override or {} cache: MutableMapping[CacheKey, typing.Any] = {} if no_cache else {**self.cache} @@ -95,7 +96,7 @@ def copy( def close(self): self.stack.close() - def __enter__(self): + def __enter__(self) -> Self: self.stack.__enter__() return self @@ -148,8 +149,7 @@ async def sub( scope: Mapping[str, typing.Any] | None = None, override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, no_cache: bool = False, - ): - + ) -> "AsyncInjectionContext": return await self.stack.enter_async_context(self.copy(scope, override, no_cache)) def copy( @@ -157,17 +157,17 @@ def copy( scope: Mapping[str, typing.Any] | None = None, override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, no_cache: bool = False, - ): + ) -> "AsyncInjectionContext": 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): + async def close(self) -> None: await self.stack.aclose() - async def __aenter__(self): + async def __aenter__(self) -> Self: await self.stack.__aenter__() return self @@ -176,5 +176,5 @@ async def __aexit__( exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, - ): + ) -> bool | None: 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..24092be --- /dev/null +++ b/fundi/injection_context.pyi @@ -0,0 +1,163 @@ +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]], + cache: MutableMapping[CacheKey, 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]], + cache: MutableMapping[CacheKey, 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], + cache: MutableMapping[CacheKey, 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]], + 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]], + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + async def inject( + self, + info: CallableInfo[Awaitable[R]], + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + async def inject( + self, + info: CallableInfo[AbstractAsyncContextManager[R]], + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + @overload + async def inject( + self, + info: CallableInfo[AbstractContextManager[R]], + override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, + no_cache: bool = False, + ) -> R: ... + async def inject( + self, + info: CallableInfo[R], + 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 From 7637ecf1c5799ede3560a432c2090bd621d39c70 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sun, 4 Jan 2026 00:46:55 +0200 Subject: [PATCH 5/6] Fix InjectionContext and AsyncInjectionContext stubs --- fundi/injection_context.pyi | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/fundi/injection_context.pyi b/fundi/injection_context.pyi index 24092be..2246a6b 100644 --- a/fundi/injection_context.pyi +++ b/fundi/injection_context.pyi @@ -60,7 +60,7 @@ class InjectionContext: def inject( self, info: CallableInfo[Generator[R, None, None]], - cache: MutableMapping[CacheKey, typing.Any] | None = None, + scope: Mapping[str, typing.Any] | None = None, override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, no_cache: bool = False, ) -> R: ... @@ -68,7 +68,7 @@ class InjectionContext: def inject( self, info: CallableInfo[AbstractContextManager[R]], - cache: MutableMapping[CacheKey, typing.Any] | None = None, + scope: Mapping[str, typing.Any] | None = None, override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, no_cache: bool = False, ) -> R: ... @@ -76,7 +76,7 @@ class InjectionContext: def inject( self, info: CallableInfo[R], - cache: MutableMapping[CacheKey, typing.Any] | None = None, + scope: Mapping[str, typing.Any] | None = None, override: Mapping[typing.Callable[..., typing.Any], typing.Any] | None = None, no_cache: bool = False, ) -> R: ... @@ -124,6 +124,7 @@ class AsyncInjectionContext: 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: ... @@ -131,6 +132,7 @@ class AsyncInjectionContext: 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: ... @@ -138,6 +140,7 @@ class AsyncInjectionContext: 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: ... @@ -145,6 +148,7 @@ class AsyncInjectionContext: 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: ... @@ -152,12 +156,14 @@ class AsyncInjectionContext: 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: ... From 3ea373d01dee078bc44f8dd5e8a23c445f6cd805 Mon Sep 17 00:00:00 2001 From: Kuyugama Date: Sun, 4 Jan 2026 02:29:55 +0200 Subject: [PATCH 6/6] Add method descriptions --- fundi/injection_context.py | 107 +++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/fundi/injection_context.py b/fundi/injection_context.py index 7b84dd4..f52f847 100644 --- a/fundi/injection_context.py +++ b/fundi/injection_context.py @@ -37,6 +37,11 @@ def dependant(sub: FromType[InjectionContext]): 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, @@ -60,6 +65,20 @@ def inject( 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 @@ -78,7 +97,17 @@ def sub( 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( @@ -87,6 +116,16 @@ def copy( 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} @@ -94,9 +133,16 @@ def copy( 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 @@ -106,10 +152,22 @@ def __exit__( 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, @@ -133,6 +191,20 @@ async def inject( 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 @@ -150,6 +222,17 @@ async def sub( 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( @@ -158,6 +241,16 @@ def copy( 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} @@ -165,9 +258,16 @@ def copy( 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 @@ -177,4 +277,11 @@ async def __aexit__( 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)