Usage#
Function injection#
This form of dependency injection works by injecting values for keyword arguments during callback execution based on the linked client. This is the current form of dependency injection implemented by Alluka.
Declaring a function's injected dependencies#
There are two styles for declaring a function's injected dependencies in Alluka:
Default descriptors#
def callback(
foo: Foo = alluka.inject(type=Foo),
bar: BarResult = alluka.inject(callback=bar_callback),
) -> None:
...
Assigning the result of alluka.inject to a parameter's default will declare it as requiring an injected type or callback.
async def callback(foo: Foo = alluka.inject()) -> None: ...
If neither type
nor callback
is passed to alluka.inject then a type dependency will be inferred from the parameter's annotation.
Warning
The type-hint will need to resolvable/accessible at runtime in the callback's module for it to be inferred (so it can't be hidden behind a typing.TYPE_CHECKING only import or using a type or operation that isn't implemented in the current python version).
Type-hint metadata#
typing.Annotated style type-hint descriptors may be used to declare the injected dependencies for a function.
def callback(
foo: typing.Annotated[Foo, alluka.inject(type=Foo)],
bar: typing.Annotated[BarResult, alluka.inject(callback=bar_callback)],
) -> None: ...
Where passing the default descriptors returned by alluka.inject to typing.Annotated lets you declare the type or callback dependency for an argument without effecting non-DI calls to the function (by leaving these parameters required).
async def callback(foo: alluka.Injected[Foo]) -> None: ...
And alluka.Injected provides a shorthand for using typing.Annotated to declare a type dependency.
Note
alluka.Injected can be safely passed to typing.Annotated as the first type argument or vice versa thanks to how Annotated handles nesting.
Calling functions with dependency injection#
client = alluka.Client()
async def callback(
argument: int,
/,
injected: alluka.Injected[Foo],
keyword_arg: str,
) -> int:
...
...
result = await client.call_with_async_di(callback, 123, keyword_arg="ok")
Client.call_with_async_di can be used to execute a function with async dependency injection. Any positional or keyword arguments which are passed with the function will be passed through to the function with the injected values.
Note
While both sync and async functions may be executed with call_with_async_di
, you'll always have to await call_with_async_di
to get the result of the call.
client = alluka.Client()
def callback(
argument: int,
/,
injected: alluka.Injected[Foo],
keyword_arg: str,
) -> int:
...
...
result = client.call_with_di(callback, 123, keyword_arg="ok")
Client.call_with_di can be used to execute a function with purely sync dependency injection. This has similar semantics to call_with_async_di
for passed through arguments but comes with the limitation that only sync functions may be used and any async callback dependencies will lead to alluka.SyncOnlyError being raised.
def foo(ctx: alluka.Injected[alluka.abc.Context]) -> None:
result = ctx.call_with_di(other_callback, 542, keyword_arg="meow")
Alternatively, Context.call_with_di and Context.call_with_async_di can be used to execute functions with dependency injection while preserving the current injection context.
async def bar(ctx: alluka.Injected[alluka.abc.Context]) -> None:
result = await ctx.call_with_async_di(other_callback, 123, keyword_arg="ok")
Automatic dependency injection#
client = alluka.Client()
@client.auto_inject
def callback(other_arg: str, value: TypeA = alluka.inject()) -> None: ...
callback(other_arg="beep") # `value` will be injected.
Client.auto_inject and Client.auto_inject_async can be used to tie a callback to a specific dependency injection client to enable implicit dependency injection without the need to call call_with_(async_)_di
every time the callback is called.
client = alluka.Client()
@client.auto_inject_async
async def callback(value: TypeA = alluka.inject()) -> None: ...
await callback() # `value` will be injected.
Client.auto_inject comes with similar limitations to Client.call_with_di in that the auto-injecting callback it creates will fail if any of the callback dependencies are asynchronous.
Using the client#
Adding type dependencies#
client = (
alluka.Client()
.set_type_dependency(TypeA, type_a_impl)
.set_type_dependency(TypeB, type_b_impl)
)
For a type dependency to work, the linked client has to have an implementation loaded for each type. Client.set_type_dependency is used to pair up the types you'll be using in alluka.inject with initialised implementations of them.
Overriding callback dependencies#
client = alluka.Client().set_callback_override(default_callback, other_callback)
While callback dependencies can work on their own without being explicitly declared on the client (unless they're relying on a type dependency themselves), they can still be overridden on a client level using Client.set_callback_override.
Injected callbacks should only be overridden with a callback which returns a compatible type but their signatures do not need to match and async callbacks can be overridden with sync with vice versa also working (although overriding a sync callback with an async callback will prevent the callback from being used in a sync context).
Local client#
Alluka provides a system in alluka.local which lets you associate an Alluka client with the local scope. This can make dependency injection easier for application code as it avoids the need to lug around an injection client or context.
The local "scope" will either be the current thread, an async event loop (e.g. asyncio event loop), an async task, or an async future.
While child async tasks and futures will inherit the local client, child threads will not.
async def callback() -> None:
result = await alluka.local.call_with_async_di(async_callback)
with alluka.local.scope_client() as client:
client.set_type_dependency(TypeA, type_a_impl)
await callback()
Either alluka.local.initialize or alluka.local.scope_client needs to be called to declare a client within the current scope before the other functionality in alluka.local can be used. These can be passed a client to declare but default to creating a new client.
These clients are then configured like normal clients and alluka.local.get can then be used to get the set client for the current scope.
scope_client
is recommended over initialize
as it avoids declaring the client globally.
client = alluka.local.initialize()
client.set_type_dependency(TypeA, type_a_impl)
...
async def callback(value: TypeA = alluka.inject()) -> None: ...
result = await alluka.local.call_with_async_di(callback)
alluka.local.call_with_async_di, alluka.local.call_with_di can be used to call a function with the dependency injection client that's set for the current scope.
@alluka.local.auto_inject_async
async def callback(value: TypeA = alluka.inject()) -> None: ...
with alluka.local.scope_client() as client:
client.set_type_dependency(TypeA, type_a_impl)
await callback()
alluka.local.auto_inject, alluka.local.auto_inject_async act a little different to the similar client methods: instead of binding a callback to a specific client to enable automatic dependency injection, these will get the local client when the auto-injecting callback is called and use this for dependency injection.
As such auto_inject
and auto_inject_async
can be used to make an auto-injecting callback before a local client has been set but any calls to the returned auto-injecting callbacks will only work within a scope where initialise
or scope_client
is in effect.
Custom injection contexts#
Under the hood Alluka builds a alluka.abc.Context for each call to a call_with_{async}_di
method.
client = alluka.Client().set_make_context(alluka.CachingContext)
alluka.Client.set_make_context can be used to change how the client creates DI contexts to customise how dependency injection behaves.
Caching injected callback results#
By default, injected callbacks are called every time they're found within the context of a dependency injection call.
alluka.CachingContext can be set as the component maker to enable the caching of the result of callback dependencies.
client = alluka.Client().set_make_context(alluka.CachingContext)
state = 0
def injected_callback() -> int:
nonlocal state
state += 1
return state
def callback(
result: int = alluka.inject(callback=injected_callback),
other_result: int = alluka.inject(callback=injected_callback),
) -> None:
print(result)
print(other_result)
client.call_with_di(callback)
print("-")
client.call_with_di(callback)
This example will result in the following output where state
is only injected once per top-level call with call_with_di
.
>>> 1
>>> -
>>> 1
This caches the results in a DI context so if the same DI context is used to call multiple callbacks with dependency injection then these cached values will be persisted between those calls.
Context-specific type dependencies#
def callback(ctx: alluka.Injected[alluka.abc.Context]) -> None:
ctx = alluka.OverridingContext(ctx).set_type_dependency(TypeA, type_a_impl)
ctx.call_with_di(other_callback)
alluka.OverridingContext to add context specific type dependency overrides to an existing DI context.
client = alluka.Client().set_type_dependency(TypeA, type_a_impl)
ctx = alluka.OverridingContext.from_client(client).set_type_dependency(TypeB, type_b_impl)
ctx.call_with_di(other_callback)
alluka.OverridingContext.from_client lets you create a context with type dependency overrides straight from an Alluka client.