Skip to content

Commit

Permalink
Implemented dependency injection
Browse files Browse the repository at this point in the history
  • Loading branch information
agronholm committed Apr 8, 2022
1 parent 8345966 commit d0f93eb
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 8 deletions.
30 changes: 30 additions & 0 deletions docs/userguide/contexts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,36 @@ The order of resource lookup is as follows:
local resource
#. search for a resource in the parent contexts

Injecting resources to functions
--------------------------------

A type-safe way to use context resources is to use `dependency injection`_. In Asphalt, this is
done by adding parameters to a function so that they have the resource type as the type annotation,
and a :class:`~.context.Dependency` instance as the default value. The function then needs to be
decorated using :func:`~.context.inject`::

from asphalt.core import Dependency, inject

@inject
async def some_function(some_arg, some_resource: MyResourceType = Dependency()):
...

To specify a non-default name for the dependency, you can pass that name as an argument to
:class:`~.context.Dependency`::

@inject
async def some_function(some_arg, some_resource: MyResourceType = Dependency('alternate')):
...

Restrictions:

* The function must be a coroutine function (``async def``)
* The dependency arguments must not be positional-only arguments
* The resources (or their relevant factories) must already be present in the context stack when
the decorated function is called, or otherwise :exc:`~.context.ResourceNotFound` is raised

.. _dependency injection: https://en.wikipedia.org/wiki/Dependency_injection

Handling resource cleanup
-------------------------

Expand Down
1 change: 1 addition & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
- Removed all uses of Python 3.5 style ``await yield_()`` from core code and documentation
- Added tracking of current Asphalt context in a :pep:`555` context variable, available via
``current_context()``
- Added dependency injection in coroutine functions via ``Dependency()`` and ``inject()``

**4.6.0** (2021-12-15)

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ classifiers = [
requires-python = ">=3.7"
dependencies = [
"importlib_metadata >= 4.4; python_version < '3.10'",
"typing_extensions; python_version < '3.10'",
"ruamel.yaml >= 0.15",
"typeguard ~= 2.0",
"async-generator ~= 1.4",
Expand Down
6 changes: 6 additions & 0 deletions src/asphalt/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"TeardownError",
"context_teardown",
"current_context",
"NoCurrentContext",
"Dependency",
"inject",
"executor",
"Event",
"Signal",
Expand All @@ -25,13 +28,16 @@
from .component import CLIApplicationComponent, Component, ContainerComponent
from .context import (
Context,
Dependency,
NoCurrentContext,
ResourceConflict,
ResourceEvent,
ResourceNotFound,
TeardownError,
context_teardown,
current_context,
executor,
inject,
)
from .event import Event, Signal, stream_events, wait_event
from .runner import run_application
Expand Down
82 changes: 80 additions & 2 deletions src/asphalt/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
"executor",
"context_teardown",
"current_context",
"Dependency",
"inject",
)

import logging
import re
import sys
import warnings
from asyncio import (
AbstractEventLoop,
Expand All @@ -22,10 +25,18 @@
get_running_loop,
iscoroutinefunction,
)
from collections.abc import Coroutine
from concurrent.futures import Executor
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from functools import wraps
from inspect import getattr_static, isasyncgenfunction, isawaitable
from inspect import (
Parameter,
getattr_static,
isasyncgenfunction,
isawaitable,
signature,
)
from traceback import format_exception
from typing import (
Any,
Expand All @@ -49,10 +60,17 @@
from asphalt.core.event import Event, Signal, wait_event
from asphalt.core.utils import callable_name, qualified_name

if sys.version_info >= (3, 10):
from typing import ParamSpec
else:
from typing_extensions import ParamSpec

logger = logging.getLogger(__name__)
factory_callback_type = Callable[["Context"], Any]
resource_name_re = re.compile(r"\w+")
T_Resource = TypeVar("T_Resource", covariant=True)
T_Resource = TypeVar("T_Resource")
T_Retval = TypeVar("T_Retval")
P = ParamSpec("P")
_current_context: ContextVar[Context | None] = ContextVar(
"_current_context", default=None
)
Expand Down Expand Up @@ -833,3 +851,63 @@ def current_context() -> Context:
raise NoCurrentContext

return ctx


@dataclass
class Dependency:
"""
Marker for declaring a parameter for dependency injection via :func:`inject`.
:param name: the resource name (defaults to ``default``)
"""

name: str = "default"
cls: type = field(init=False)


def inject(
func: Callable[P, Coroutine[Any, Any, T_Retval]]
) -> Callable[P, Coroutine[Any, Any, T_Retval]]:
"""
Wrap the given coroutine function for use with dependency injection.
Parameters with dependencies need to be annotated and have a :class:`Dependency` instance as
the default value.
"""

@wraps(func)
async def inject_wrapper(*args, **kwargs) -> T_Retval:
ctx = current_context()
resources: dict[str, Any] = {}
for argname, dependency in injected_resources.items():
resource: Any = ctx.require_resource(dependency.cls, dependency.name)
if isawaitable(resource):
resource = await resource

resources[argname] = resource

return await func(*args, **kwargs, **resources)

if not iscoroutinefunction(func):
raise TypeError(f"{callable_name(func)!r} is not a coroutine function")

sig = signature(func)
injected_resources: dict[str, Dependency] = {}
for param in sig.parameters.values():
if isinstance(param.default, Dependency):
if param.kind is Parameter.POSITIONAL_ONLY:
raise TypeError(
f"Cannot inject dependency to positional-only parameter {param.name!r}"
)

if param.annotation is Parameter.empty:
raise TypeError(
f"Dependency for parameter {param.name!r} of function "
f"{callable_name(func)!r} is missing the type annotation"
)

param.default.cls = param.annotation
injected_resources[param.name] = param.default

return inject_wrapper
54 changes: 48 additions & 6 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@

from asphalt.core import (
Context,
Dependency,
NoCurrentContext,
ResourceConflict,
ResourceNotFound,
TeardownError,
callable_name,
context_teardown,
executor,
)
from asphalt.core.context import (
NoCurrentContext,
ResourceContainer,
TeardownError,
current_context,
executor,
inject,
)
from asphalt.core.context import ResourceContainer


@pytest.fixture
Expand Down Expand Up @@ -649,3 +649,45 @@ async def generator() -> AsyncGenerator:
assert current_context() is ctx

pytest.raises(NoCurrentContext, current_context)


class TestDependencyInjection:
@pytest.mark.asyncio
async def test_static_resources(self):
@inject
async def injected(
foo: int, bar: str = Dependency(), *, baz: str = Dependency("alt")
):
return foo, bar, baz

async with Context() as ctx:
ctx.add_resource("bar_test")
ctx.add_resource("baz_test", "alt")
foo, bar, baz = await injected(2)

assert foo == 2
assert bar == "bar_test"
assert baz == "baz_test"

@pytest.mark.asyncio
async def test_missing_annotation(self):
async def injected(foo: int, bar: str = Dependency(), *, baz=Dependency("alt")):
pass

pytest.raises(TypeError, inject, injected).match(
f"Dependency for parameter 'baz' of function "
f"'{__name__}.{self.__class__.__name__}.test_missing_annotation.<locals>.injected' is "
f"missing the type annotation"
)

@pytest.mark.asyncio
async def test_missing_resource(self):
@inject
async def injected(foo: int, bar: str = Dependency()):
pass

with pytest.raises(ResourceNotFound) as exc:
async with Context():
await injected(2)

exc.match("no matching resource was found for type=str name='default'")
13 changes: 13 additions & 0 deletions tests/test_context_py38.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import pytest

from asphalt.core import Dependency, inject


@pytest.mark.asyncio
async def test_dependency_injection_posonly_argument():
async def injected(foo: int, bar: str = Dependency(), /):
pass

pytest.raises(TypeError, inject, injected).match(
"Cannot inject dependency to positional-only parameter 'bar'"
)

0 comments on commit d0f93eb

Please sign in to comment.