Unified way to call coroutines and normal functions?

Library authors often allow their users to use coroutines and functions interchangeably. Examples include FastAPI, Django, asgiref etc.

Normal functions can be called in async context in two ways:

  1. Run asynchronously in executor (asyncio.to_thread, asgiref.sync.SyncToAsync, etc.) – this is good for long-running functions
  2. Call small blocking function right here, right now (logging etc.)

Does it make sense to add to asyncio a function that will either await for coroutine or call normal function immediately, covering the case (2)?

Something like

async def call[**P, R](
    coro_or_func: Callable[P, Awaitable[R] | R],
    *args: P.args,
    **kwargs: P.kwargs,
) -> R:
    if inspect.iscoroutinefunction(coro_or_func):
        return await coro_or_func(*args, **kwargs)
    elif callable(coro_or_func):
        return coro_or_func(*args, **kwargs)
    else:
        raise TypeError()

UPD: Use cases – receiving callable (func or coro) as parameter:

  • event handlers
  • route handlers
  • interchangeable behaviour for libraries that support both sync and async APIs

have you tried your function in a type checker? Awaitable[R] | R doesn’t work very well.

from collections.abc import Callable, Awaitable
import inspect
from typing import Any

async def call[**P, R](
    coro_or_func: Callable[P, Awaitable[R] | R],
    *args: P.args,
    **kwargs: P.kwargs,
) -> R:
    c = coro_or_func(*args, **kwargs)
    if isinstance(c, Awaitable):
        return await c
    return c
    
    
async def ham(spam: str) -> int:
    return 1
    
    
async def main() -> None:
    v = await call(ham, spam="eggs")
    reveal_type(v)

results in:

demo.py:21: error: Need type annotation for "v"  [var-annotated]
demo.py:21: error: Argument 1 to "call" has incompatible type "Callable[[str], Coroutine[Any, Any, int]]"; expected "Callable[[str], Awaitable[Never]]"  [arg-type]
demo.py:22: note: Revealed type is "Any"

@graingert well, I didn’t try to provide fully type-safe example, just wanted to describe the idea, but this could be fixed with @overload:

from collections.abc import Awaitable, Callable
import inspect
from typing import assert_type, overload


@overload
async def call[**P, R](
    coro_or_func: Callable[P, Awaitable[R]],
    *args: P.args,
    **kwargs: P.kwargs,
) -> R: ...
@overload
async def call[**P, R](
    coro_or_func: Callable[P, R],
    *args: P.args,
    **kwargs: P.kwargs,
) -> R: ...
async def call(coro_or_func, *args, **kwargs):
    if inspect.iscoroutinefunction(coro_or_func):
        return await coro_or_func(*args, **kwargs)
    elif callable(coro_or_func):
        return coro_or_func(*args, **kwargs)
    else:
        raise TypeError()


async def ham(spam: str) -> int:
    return 1


async def main() -> None:
    v = await call(ham, spam="eggs")
    assert_type(v, int)

Another option could be allowing asyncio.run in async context:

def foo(s: str) -> str:
    return s

async def afoo(s: str) -> str:
    return s

async def main() -> None:
    assert await asyncio.run(foo('bar')) == 'bar'
    assert await asyncio.run(afoo('bar')) == 'bar'

you can just call the logging function and omit an await:

async def some_route_handler():
    logging.getLogger(__name__).info("some info")
    ...
    await anyio.wait_readable(sock)

I was meaning cases when we are receiving coroutine or function as parameter. This is common situation in many projects.

It probably shouldn’t be common to support receiving either and not know which. You can always provide two separate interfaces and let the user call with the one matching what they are writing. This removes any runtime overhead for checking what you have and vastly simplifies code flow when reviewing. checking yourself with inspect is “fine” if you have no other option, or this is only done during application setup such as with route handlers, but your unified calling won’t work there. For example, FastAPI wants sync function to run in a thread as a route handler. It also has runtime overhead to check each time. Putting this overhead in the standard library instead as an intended pattern will just lead people to worse solutions IMO.

Making asyncio.run work from an async context will be full of other issues, and I think it’s a good thing that asyncio event loops aren’t reentrant.

1 Like

@mikeshardmind thank you for your review! The performance argument makes sense to me. For the asyncio.run I didn’t mean reentrant loop, but rather a different naming for the call() function from original post. Like asyncio.run behaves differently in async context. This was probably a bad idea from the typing point of view, asking the same function to return a regular value or awaitable depending on the sync/async context where it was called from.