Skip to content

Commit

Permalink
Add Async Support (#49)
Browse files Browse the repository at this point in the history
  • Loading branch information
biniona authored Oct 9, 2024
1 parent cb530aa commit a5b216a
Show file tree
Hide file tree
Showing 11 changed files with 717 additions and 23 deletions.
18 changes: 18 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Versioning

`minject` uses semantic versioning. To learn more about semantic versioning, see the [semantic versioning specification](https://semver.org/#semantic-versioning-200).

# Changelog

## v1.1.0-beta.1

Add support for async Python. This version introduces the following methods and decorators:

- `Registry.__aenter__`
- `Registry.__aexit__`
- `Registry.aget`
- `@async_context`

## v1.0.0

- Initial Release
2 changes: 1 addition & 1 deletion minject/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def __init__(self, api, path): ...
something = registry['something_i_need']
"""

__version__ = "1.0.0"
__version__ = "1.1.0-beta.1"

from . import inject
from .inject_attrs import inject_define as define, inject_field as field
Expand Down
47 changes: 47 additions & 0 deletions minject/asyncio_extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
This module provides fallback implementations of asyncio features that
are not available in Python 3.7.
"""

try:
# Python 3.7 mypy raises attr-defined error for to_thread, so
# we must ignore it here.
from asyncio import to_thread # type: ignore[attr-defined]
# This is copy pasted from here: https://github.com/python/cpython/blob/03775472cc69e150ced22dc30334a7a202fc0380/Lib/asyncio/threads.py#L1-L25
except ImportError:
"""High-level support for working with threads in asyncio"""

import contextvars
import functools
from asyncio import events

# Minject Specific Edit: I commented out the following line
# and moved it out of the try - except block
# __all__ = "to_thread",

# Minject Specific Edit: I removed the '/' from the function signature,
# as this is not supported in python 3.7 (added in python 3.8).
# The '/' forces that "func" be passed positionally (I.E. first argument
# to to_thread). Users of this extension must be careful to pass the argument
# to "func" positionally, or there could be different behavior
# when using minject in python 3.7 and python 3.9+. I added a "type: ignore"
# comment to silence mypy errors related to defining a function with a
# different signature.
# original asyncio source: "async def to_thread(func, /, *args, **kwargs):"
async def to_thread(func, *args, **kwargs): # type: ignore
"""Asynchronously run function *func* in a separate thread.
Any *args and **kwargs supplied for this function are directly passed
to *func*. Also, the current :class:`contextvars.Context` is propagated,
allowing context variables from the main thread to be accessed in the
separate thread.
Return a coroutine that can be awaited to get the eventual result of *func*.
"""
loop = events.get_running_loop()
ctx = contextvars.copy_context()
func_call = functools.partial(ctx.run, func, *args, **kwargs)
return await loop.run_in_executor(None, func_call)


__all__ = "to_thread"
80 changes: 75 additions & 5 deletions minject/inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,37 @@

import itertools
import os
from typing import Any, Callable, Dict, Optional, Sequence, Type, TypeVar, Union, cast, overload
from typing import (
Any,
Callable,
Dict,
Optional,
Sequence,
Type,
TypeVar,
Union,
cast,
overload,
)

from typing_extensions import TypeGuard, assert_type

from typing_extensions import TypeGuard
from minject.asyncio_extensions import to_thread
from minject.types import _AsyncContext

from .metadata import RegistryMetadata, _gen_meta, _get_meta
from .metadata import _INJECT_METADATA_ATTR, RegistryMetadata, _gen_meta, _get_meta
from .model import (
Deferred,
DeferredAny,
RegistryKey, # pylint: disable=unused-import
Resolver,
resolve_value,
)
from .types import _MinimalMappingProtocol
from .types import _AsyncContext, _MinimalMappingProtocol

T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)
T_async_context = TypeVar("T_async_context", bound=_AsyncContext)
R = TypeVar("R")


Expand Down Expand Up @@ -70,6 +85,19 @@ def wrap(cls: Type[T]) -> Type[T]:
return wrap


def async_context(cls: Type[T_async_context]) -> Type[T_async_context]:
"""
Declare that a class is as an async context manager
that can be initialized by the registry through aget(). This
is to distinguish the class from an async context manager that
should not be initialized by the registry (an example of
this being asyncio.Lock).
"""
meta = _gen_meta(cls)
meta.is_async_context = True
return cls


def define(
base_class: Type[T],
_close: Optional[Callable[[T], None]] = None,
Expand All @@ -78,7 +106,9 @@ def define(
"""Create a new registry key based on a class and optional bindings."""
meta = _get_meta(base_class)
if meta:
meta = RegistryMetadata(base_class, bindings=dict(meta.bindings))
meta = RegistryMetadata(
base_class, is_async_context=meta.is_async_context, bindings=dict(meta.bindings)
)
meta.update_bindings(**bindings)
else:
meta = RegistryMetadata(base_class, bindings=bindings)
Expand All @@ -91,6 +121,29 @@ def _is_type(key: "RegistryKey[T]") -> TypeGuard[Type[T]]:
return isinstance(key, type)


def _is_key_async(key: "RegistryKey[T]") -> bool:
"""
Check whether a registry key is an "async", or in other words
marked for async initialization within the registry with @async_context.
If a key is "async", it can be initialized through Registry.aget.
"""
# At present, we only consider objects with RegistryMetadata.is_async_context
# set to True to be "async", or able to be initialized through Registry.aget.
# In the future, we likely will support initializing both async and non-async
# objects through aget, but we are deferring implementing this until
# we have a bit more experience using the async Registry API.
if isinstance(key, str):
return False
elif isinstance(key, RegistryMetadata):
return key.is_async_context
else:
assert_type(key, Type[T])
inject_metadata = _get_meta(key)
if inject_metadata is None:
return False
return inject_metadata.is_async_context


class _RegistryReference(Deferred[T_co]):
"""Reference to an object in the registry to be loaded later.
(you should not instantiate this class directly, instead use the
Expand All @@ -103,6 +156,11 @@ def __init__(self, key: "RegistryKey[T_co]") -> None:
def resolve(self, registry_impl: Resolver) -> T_co:
return registry_impl.resolve(self._key)

async def aresolve(self, registry_impl: Resolver) -> T_co:
if _is_key_async(self._key):
return await registry_impl._aresolve(self._key)
return await to_thread(registry_impl.resolve, self._key)

@property
def type_of_object_referenced_in_key(self) -> "Type[T_co]":
if type(self.key) == RegistryMetadata:
Expand Down Expand Up @@ -188,6 +246,9 @@ def resolve(self, registry_impl: Resolver) -> T_co:
kwargs[key] = resolve_value(registry_impl, arg)
return self.func()(*args, **kwargs)

async def aresolve(self, registry_impl: Resolver) -> T_co:
raise NotImplementedError("Have not implemented async registry function")

def func(self) -> Callable[..., T_co]:
return self._func

Expand Down Expand Up @@ -273,6 +334,9 @@ def resolve(self, registry_impl: Resolver) -> T_co:
else:
return cast(T_co, self._default)

async def aresolve(self, registry_impl: Resolver) -> T_co:
return await to_thread(self.resolve, registry_impl)

@property
def key(self) -> Optional[str]:
return self._key
Expand Down Expand Up @@ -316,6 +380,9 @@ def resolve(self, registry_impl: Resolver) -> T_co:
return self._default
return cast(T_co, sub)

async def aresolve(self, registry_impl: Resolver) -> T_co:
return await to_thread(self.resolve, registry_impl)


def nested_config(
keys: Union[Sequence[str], str], default: Union[T, _RaiseKeyError] = RAISE_KEY_ERROR
Expand Down Expand Up @@ -375,5 +442,8 @@ class _RegistrySelf(Deferred[Resolver]):
def resolve(self, registry_impl: Resolver) -> Resolver:
return registry_impl

async def aresolve(self, registry_impl: Resolver) -> Resolver:
return await to_thread(self.resolve, registry_impl)


self_tag = _RegistrySelf()
14 changes: 13 additions & 1 deletion minject/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
TypeVar,
)

from .model import DeferredAny, RegistryKey, resolve_value
from .model import DeferredAny, RegistryKey, aresolve_value, resolve_value
from .types import Kwargs

if TYPE_CHECKING:
Expand Down Expand Up @@ -106,12 +106,14 @@ def __init__(
cls: Type[T_co],
close: Optional[Callable[[T_co], None]] = None,
bindings: Optional[Kwargs] = None,
is_async_context: bool = False,
):
self._cls = cls
self._bindings = bindings or {}

self._close = close
self._interfaces = [cls for cls in inspect.getmro(cls) if cls is not object]
self.is_async_context = is_async_context

@property
def interfaces(self) -> Sequence[Type]:
Expand Down Expand Up @@ -159,6 +161,16 @@ def _init_object(self, obj: T_co, registry_impl: "Registry") -> None: # type: i

self._cls.__init__(obj, **init_kwargs)

async def _ainit_object(self, obj: T_co, registry_impl: "Registry") -> None: # type: ignore[misc]
"""
asynchronous version of _init_object. Calls _aresolve instead
of _resolve.
"""
init_kwargs = {}
for name_, value in self._bindings.items():
init_kwargs[name_] = await aresolve_value(registry_impl, value)
self._cls.__init__(obj, **init_kwargs)

def _close_object(self, obj: T_co) -> None: # type: ignore[misc]
if self._close:
self._close(obj)
Expand Down
34 changes: 34 additions & 0 deletions minject/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ class Resolver(abc.ABC):
def resolve(self, key: "RegistryKey[T]") -> T:
...

async def _aresolve(self, key: "RegistryKey[T]") -> T:
"""
Resolve a key into an instance of that key. The key must be marked
with the @async_context decorator.
"""
raise NotImplementedError("Please implement _aresolve.")

async def _push_async_context(self, key: Any) -> Any:
"""
Push an async context onto the context stack maintained by the Resolver.
This is necessary to enter/close the context of an object
marked with @async_context.
"""
raise NotImplementedError

@property
@abc.abstractmethod
def config(self) -> RegistryConfigWrapper:
Expand All @@ -52,6 +67,15 @@ class Deferred(abc.ABC, Generic[T_co]):
def resolve(self, registry_impl: Resolver) -> T_co:
...

@abc.abstractmethod
async def aresolve(self, registry_impl: Resolver) -> T_co:
"""
Resolve a deferred object into an instance of the object. The object,
may or may not be asynchronous. If the object is asynchronous (marked with @async_context),
resolve the object asynchronously. Otherwise, resolve synchronously.
"""
...


Resolvable = Union[Deferred[T_co], T_co]
# Union of Deferred and Any is just Any, but want to call out that a Deffered is quite common
Expand All @@ -69,3 +93,13 @@ def resolve_value(registry_impl: Resolver, value: Resolvable[T]) -> T:
return value.resolve(registry_impl)
else:
return value


async def aresolve_value(registry_impl: Resolver, value: Resolvable[T]) -> T:
"""
Async version of resolve_value, which calls aresolve on Deferred instances.
"""
if isinstance(value, Deferred):
return await value.aresolve(registry_impl)
else:
return value
Loading

0 comments on commit a5b216a

Please sign in to comment.