blob: 5a37cf8f9fdc18ecbc61426293391d73a8a52d74 [file] [log] [blame]
# Copyright 2024 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import asyncio
from typing import Any, Callable, Coroutine, ParamSpec, TypeVar
_Params = ParamSpec("_Params")
_Yield = TypeVar("_Yield")
_Send = TypeVar("_Send")
_Ret = TypeVar("_Ret")
class AsyncAdapterError(Exception):
"""Raised when an asyncmethod is used outside of an AsyncAdapter."""
class AsyncAdapter:
"""A wrapper or mixin that supports async calls in a synchronous context.
This can be used with any object where you wish to expose functions as
synchronous when in reality they are implemented in async. This is for
convenience in areas like Mobly where tests are expected to be run as
synchronous methods. Or in places where you intend to have things like
`asyncio.Queue` used across multiple function calls.
The implementation is simple: the class using this adapter is given an
async loop to itself. This is the main loop used for every function call
to this object.
To expose an async function as synchronous, just use the `asyncmethod`
decorator.
For example:
```python
class TestClass(AsyncAdapter, BaseTestClass):
def __init__(self):
AsyncAdapter.__init__(self)
# ...
@asyncmethod
async def foo(self) -> None:
await asyncio.sleep(1)
```
In the above, the `foo` method will be exposed as a synchronous method,
but inside it is async code.
If you're using this AsyncAdapter as a mixin and you're getting exceptions
when using the `asyncmethod` decorator, make sure to put this first in the
inheritance order to ensure proper initialization based on Python's
method resolution order.
Limitations:
It is not currently possible to call one async-wrapped method from inside
another async-wrapped method. To workaround this one will have to write
regular async helper functions.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._async_adapter_loop = asyncio.new_event_loop()
def loop(self) -> asyncio.AbstractEventLoop:
"""Returns a copy of this class's event loop.
This is intended for spawning tasks in this class.
"""
return self._async_adapter_loop
def cancel_task(self, task: asyncio.Task[Any]) -> None:
"""Cancel a task then verify it has been cancelled.
Args:
task: The task to cancel
Raises:
RuntimeError: failed cancel verification
"""
if not task.cancel():
# Task was already done or cancelled, nothing else to do.
return
# Wait for task to completely cancel.
try:
self.loop().run_until_complete(task)
raise RuntimeError(
"Expected cancellation of task to raise CancelledError"
)
except asyncio.exceptions.CancelledError:
pass # expected
def asyncmethod(
func: Callable[_Params, Coroutine[_Yield, _Send, _Ret]],
) -> Callable[_Params, _Ret]:
"""A decorator to expose an async method as synchronous.
This should ONLY be used with classes that inherit `AsyncAdapter`.
"""
def wrapper(*args: _Params.args, **kwargs: _Params.kwargs) -> _Ret:
coro = func(*args, **kwargs)
try:
loop = getattr(args[0], "_async_adapter_loop") # args[0] == self
except AttributeError as e:
raise AsyncAdapterError(
"`asyncmethod` was used outside of an `AsyncAdapter`. "
+ "Your class must inherit from "
+ "`fuchsia_controller_py.wrappers.AsyncAdapter` to use this "
+ "decorator. If you're already inheriting this and you're "
+ "seeing this exception, put `AsyncAdapter` first in your "
+ "inheritance order."
) from e
return loop.run_until_complete(coro)
return wrapper