From abfb528e1f30f980fe8daf67bf6c6095dee37f95 Mon Sep 17 00:00:00 2001 From: FasterSpeeding Date: Mon, 29 Apr 2024 22:02:35 +0100 Subject: [PATCH] Update usage guide and some docs (#382) --- alluka/local.py | 13 ++- docs/reference/local.md | 3 + docs/usage.md | 170 +++++++++++++++++++++----------------- docs_src/usage.py | 178 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 3 + pyproject.toml | 8 +- 6 files changed, 297 insertions(+), 78 deletions(-) create mode 100644 docs/reference/local.md create mode 100644 docs_src/usage.py diff --git a/alluka/local.py b/alluka/local.py index d38cc4db..7b4bc12a 100644 --- a/alluka/local.py +++ b/alluka/local.py @@ -28,7 +28,10 @@ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -"""Standard functions for using a scope local dependency injection client. +"""Standard functions for using a local scope dependency injection client. + +The "scope" will either be the current thread, an asynchronous runtime or an +asynchronous event/task. .. note:: This module's functionality will only work if @@ -75,7 +78,10 @@ def initialize(client: typing.Optional[abc.Client] = None, /) -> abc.Client: """Link or initialise an injection client for the current scope. - This uses the contextvars package to store the client. + This uses [contextvars][] to store the client and therefore will not be + inherited by child threads. + + [scope_client][alluka.local.scope_client] is recommended over this. Parameters ---------- @@ -109,6 +115,9 @@ def initialize(client: typing.Optional[abc.Client] = None, /) -> abc.Client: def scope_client(client: typing.Optional[abc.Client] = None, /) -> collections.Generator[abc.Client, None, None]: """Declare a client for the scope within a context manager. + This uses [contextvars][] to store the client and therefore will not be + inherited by child threads. + Examples -------- ```py diff --git a/docs/reference/local.md b/docs/reference/local.md new file mode 100644 index 00000000..76aa5b78 --- /dev/null +++ b/docs/reference/local.md @@ -0,0 +1,3 @@ +# alluka.local + +::: alluka.local diff --git a/docs/usage.md b/docs/usage.md index 9e8178c5..4d67a2bc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,7 +3,7 @@ ## 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 main form of dependency injection implemented by +execution based on the linked client. This is the current form of dependency injection implemented by Alluka. ### Declaring a function's injected dependencies @@ -13,21 +13,15 @@ There are two styles for declaring a function's injected dependencies in Alluka: #### Default descriptors ```py -def callback( - foo: Foo = alluka.inject(type=Foo) - bar: BarResult = alluka.inject(callback=bar_callback) -) -> None: - ... +--8<-- "./docs_src/usage.py:32:36" ``` Assigning the result of [alluka.inject][] to a parameter's default will declare it as requiring an injected type or callback. ```py -async def callback( - foo: Foo = alluka.inject() -) -> None: - ... +--8<-- "./docs_src/usage.py:41:41" + ``` If neither `type` nor `callback` is passed to [alluka.inject][] then a type dependency will be @@ -44,11 +38,7 @@ inferred from the parameter's annotation. for a function. ```py -def callback( - foo: typing.Annotated[Foo, alluka.inject(type=Foo)], - bar: typing.Annotated[BarResult, alluka.inject(callback=bar_callback)] -) -> None: - ... +--8<-- "./docs_src/usage.py:45:48" ``` Where passing the default descriptors returned by [alluka.inject][] to [typing.Annotated][] lets @@ -56,10 +46,8 @@ you declare the type or callback dependency for an argument without effecting no function (by leaving these parameters required). ```py -async def callback( - foo: alluka.Injected[Foo] -) -> None: - ... +--8<-- "./docs_src/usage.py:52:52" + ``` And [alluka.Injected][] provides a shorthand for using [typing.Annotated][] to declare a type @@ -72,66 +60,60 @@ dependency. ### Calling functions with dependency injection ```py -client: alluka.Client - -async def callback( - argument: int, - /, - injected: alluka.Injected[Foo], - keyword_arg: str, -) -> int: - ... +--8<-- "./docs_src/usage.py:57:69" -... - -result = await client.call_with_async_di(callback, 123, keyword_arg="ok") ``` -To execute a function with async dependency injection [alluka.abc.Client.call_with_async_di][] should -be called with the function and any positional or keyword arguments to pass through alongside the -the injected arguments. +[Client.call_with_async_di][alluka.abc.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. ```py -client: alluka.Client - -def callback( - argument: int, - /, - injected: alluka.Injected[Foo], - keyword_arg: str, -) -> int: - ... +--8<-- "./docs_src/usage.py:75:87" +``` -... +[Client.call_with_di][alluka.abc.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. -result = client.call_with_di(callback, 123, keyword_arg="ok") +```py +--8<-- "./docs_src/usage.py:95:96" ``` -To execute a function with purely sync dependency injection [alluka.abc.Client.call_with_di][] can be -used with similar semantics to `call_with_async_di` for passed through arguments but this comes with the -limitation that only sync functions may be used and any dependency on async callback dependencies -will lead to [alluka.SyncOnlyError][] being raised. +Alternatively, [Context.call_with_di][alluka.abc.Context.call_with_di] and +[Context.call_with_async_di][alluka.abc.Context.call_with_async_di] can be used to execute functions +with dependency injection while preserving the current injection context. ```py -def foo(ctx: alluka.Inject[alluka.abc.Context]) -> None: - result = ctx.call_with_di(other_callback, 542, keyword_arg="meow") - +--8<-- "./docs_src/usage.py:100:101" ``` -Alternatively, [alluka.abc.Context.call_with_di][] and [alluka.abc.Context.call_with_async_di][] can be used -to execute functions with dependency injection while preserving the current injection context. + + +### Automatic dependency injection ```py -async def bar(ctx: alluka.Inject[alluka.abc.Context]) -> None: - result = await ctx.call_with_async_di(other_callback, 123, keyword_arg="ok") +--8<-- "./docs_src/usage.py:164:169" ``` - +[Client.auto_inject][alluka.abc.Client.auto_inject_async] and +[Client.auto_inject_async][alluka.abc.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. + +```py +--8<-- "./docs_src/usage.py:173:178" +``` +[Client.auto_inject][alluka.abc.Client.auto_inject] comes with similar limitations to +[Client.call_with_di][alluka.abc.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 @@ -140,29 +122,69 @@ async def bar(ctx: alluka.Inject[alluka.abc.Context]) -> None: ### Adding type dependencies ```py -client = ( - alluka.Client() - .set_type_dependency(TypeA, type_a_impl) - .set_type_dependency(TypeB, type_b_impl) -) +--8<-- "./docs_src/usage.py:114:118" ``` -For a type dependency to work, the linked client will have to have an implementation loaded for it. -While right now the only way to load type dependencies is with the lower-level -[alluka.abc.Client.set_type_dependency][] method, more approaches and helpers will be added in the -future as Alluka is further developed. +For a type dependency to work, the linked client has to have an implementation loaded for each type. +[Client.set_type_dependency][alluka.abc.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 ```py -client = alluka.Client().set_callback_override(callback, other_callback) +--8<-- "./docs_src/usage.py:126:126" +``` + +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][alluka.abc.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. + +```py +--8<-- "./docs_src/usage.py:144:150" +``` + +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. + +```py +--8<-- "./docs_src/usage.py:130:137" +``` + +[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. + +```py +--8<-- "./docs_src/usage.py:154:160" ``` -While (unlike type dependencies) 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 [alluka.abc.Client.set_callback_override][]. +[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. -Generally speaking you should only ever override an injected callback 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 the latter will prevent callbacks from being -used in an async context). +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. diff --git a/docs_src/usage.py b/docs_src/usage.py new file mode 100644 index 00000000..6534506c --- /dev/null +++ b/docs_src/usage.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# Tanjun Examples - A collection of examples for Tanjun. +# Written in 2023 by Faster Speeding Lucina@lmbyrne.dev +# +# To the extent possible under law, the author(s) have dedicated all copyright +# and related and neighboring rights to this software to the public domain worldwide. +# This software is distributed without any warranty. +# +# You should have received a copy of the CC0 Public Domain Dedication along with this software. +# If not, see . + +# pyright: reportUnusedFunction=none +# pyright: reportUnusedVariable=none + +import typing + +import alluka +import alluka.local + + +class Foo: ... + + +class BarResult: ... + + +bar_callback = BarResult + + +# fmt: off +def default_descriptor_example() -> None: + def callback( + foo: Foo = alluka.inject(type=Foo), + bar: BarResult = alluka.inject(callback=bar_callback), + ) -> None: + ... +# fmt: on + + +def default_descriptor_inferred_type_example() -> None: + async def callback(foo: Foo = alluka.inject()) -> None: ... + + +def type_hint_example() -> None: + def callback( + foo: typing.Annotated[Foo, alluka.inject(type=Foo)], + bar: typing.Annotated[BarResult, alluka.inject(callback=bar_callback)], + ) -> None: ... + + +def injected_example() -> None: + async def callback(foo: alluka.Injected[Foo]) -> None: ... + + +# fmt: off +async def calling_with_di_example() -> None: + 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") +# fmt: on + + +# fmt: off +def calling_with_di_sync_example() -> None: + 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") +# fmt: on + + +def other_callback() -> None: ... + + +def context_di_example() -> None: + def foo(ctx: alluka.Injected[alluka.abc.Context]) -> None: + result = ctx.call_with_di(other_callback, 542, keyword_arg="meow") + + +def async_context_di_example() -> None: + async def bar(ctx: alluka.Injected[alluka.abc.Context]) -> None: + result = await ctx.call_with_async_di(other_callback, 123, keyword_arg="ok") + + +class TypeA: ... + + +TypeB = TypeA +type_a_impl = TypeA() +type_b_impl = TypeB() + + +# fmt: off +def setting_dependencies_example() -> None: + client = ( + alluka.Client() + .set_type_dependency(TypeA, type_a_impl) + .set_type_dependency(TypeB, type_b_impl) + ) +# fmt: on + + +default_callback = other_callback + + +def override_callback_example() -> None: + client = alluka.Client().set_callback_override(default_callback, other_callback) + + +async def initialize_example() -> None: + 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) + + +async def async_callback() -> None: ... + + +async def scoped_example() -> None: + 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() + + +async def local_auto_inject_example() -> None: + @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() + + +def auto_inject_example() -> None: + client = alluka.Client() + + @client.auto_inject + def callback(other_arg: str, value: TypeA = alluka.inject()) -> None: ... + + callback(other_arg="beep") # `value` will be injected. + + +async def auto_inject_async_example() -> None: + client = alluka.Client() + + @client.auto_inject_async + async def callback(value: TypeA = alluka.inject()) -> None: ... + + await callback() # `value` will be injected. diff --git a/mkdocs.yml b/mkdocs.yml index 4871a89e..5a31378b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,8 @@ theme: markdown_extensions: - admonition - pymdownx.details + - pymdownx.snippets: + dedent_subsections: true - pymdownx.superfences - markdown_include.include - toc: @@ -65,4 +67,5 @@ plugins: watch: - alluka - CHANGELOG.md + - docs_src - README.md diff --git a/pyproject.toml b/pyproject.toml index 16c8567b..38429c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,7 @@ ignore = [ # Doc errors don't matter in the tests per-file-ignores = [ "alluka/py.typed: D100", + "docs_src/*.py: ASYNC910, ASYNC911, DALL000, D100, D101, D103, E800, FA100, FA101, F841, INP001, M511, N806, TC001, TC101, VNE002", "noxfile.py: D100, FA101, F401, F403, INP001", "tests/*.py: ASYNC910, CCE002, DALL000, D100, D101, D103, D104, FA100, FA101, M511" ] @@ -149,7 +150,7 @@ mypy_allowed_to_fail = true mypy_targets = ["alluka"] path_ignore = "^alluka\\/_vendor\\/" project_name = "alluka" -top_level_targets = ["./alluka", "./noxfile.py", "./tests"] +top_level_targets = ["./alluka", "./docs_src", "./noxfile.py", "./tests"] [tool.piped.github_actions.freeze_for_pr] [tool.piped.github_actions.lint] @@ -168,9 +169,12 @@ python_versions = ["3.9", "3.10", "3.11", "3.12"] [tool.piped.github_actions.verify_locks] [tool.piped.github_actions.verify_types] +[tool.pycln] +exclude = "docs_src" + [tool.pyright] exclude = ["alluka/_vendor"] -include = ["alluka", "noxfile.py", "tests"] +include = ["alluka", "docs_src", "noxfile.py", "tests"] pythonVersion = "3.9" typeCheckingMode = "strict"