Problem
A common scenario for library authors is that they accept some callable as a callback for user-defined logic.
If the library author wants to add support for async methods, some high-level changes are usually needed, but there’s a problem which ends up percolating down through all sorts of utility functions.
Rather than toy examples, I’ll use some of the code I’ve been working on in a branch of the webargs
library:
def _load_location_data(self, *, schema, req, location):
loader_func = self._get_loader(location)
return loader_func(req, schema)
async def _async_load_location_data(self, *, schema, req, location):
loader_func = self._get_loader(location)
if asyncio.iscoroutinefunction(loader_func):
data = await loader_func(req, schema)
else:
data = loader_func(req, schema)
return data
Both of these functions are “the same”, but we need them both. This isn’t so bad for a single function, but stack up a few distinct hooks and methods, and you end up effectively doubling the size of a lot of the plumbing in the project to allow a completely async call path alongside the sync one.
Existing solution for decorators
The interface provided to users sometimes needs to show the difference between the two versions of the same code, e.g. Parser.parse
is sync, Parser.async_parse
is async. That happens anywhere that the library exposes a bare function call which must become async-capable.
But we can hide it a lot of the time using a decorator and a quick check:
def decorator(func):
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs): ...
else:
@functools.wraps(func)
def wrapper(*args, **kwargs): ...
return wrapper
This works great for cases like
import flask
from webargs.flaskparser import parser
app = flask.Flask(__name__)
@app.route("/foo")
@parser.use_args(...)
async def foo(...): ...
I am therefore not super interested in trying to find a better way of presenting an interface for users to call sync or async variants of library code. Decorators solve this pretty well where we can use them. And it’s okay to have to support foo()
and async_foo()
as different entry points into library code where necessary. The problem is that it’s not just a matter of having foo()
and async_foo()
at the top level, but a “shadow copy” of your code inside the library to keep the sync and async paths separate.
Past discussions
This issue has been discussed before, in particular
- Is there a way to define a function that can be used as a nomal function and an async function?
- Wrapping async functions for use in sync code - #14 by TobiasHT
both seem relevant.
However, I don’t see anyone asking for what – as a library author – seems like the best solution:
Is there a way in which the language could be changed such that building the async and non-async variants of the same function could be automated or simplified?
If there’s another past thread I should read, please let me know.
Ideal solution
Today I have this:
class Parser:
def _load_location_data(self, *, schema, req, location):
loader_func = self._get_loader(location)
return loader_func(req, schema)
async def _async_load_location_data(self, *, schema, req, location):
loader_func = self._get_loader(location)
if asyncio.iscoroutinefunction(loader_func):
data = await loader_func(req, schema)
else:
data = loader_func(req, schema)
return data
async def _async_other_helper_func(self, ...):
return await self._async_load_location_data(...)
def _other_helper_func(self, ...):
return self._load_location_data(...)
def public_func(self, ...):
return self._other_helper_func.call_sync(...)
async def async_public_func(self, ...):
return await self._other_helper_func.call_sync(...)
and what I want to write instead is this:
class Parser:
maybe_async def _load_location_data(self, *, schema, req, location):
loader_func = self._get_loader(location)
if (
asyncio.iscoroutinefunction(loader_func) and
MAGIC_is_currently_async
):
data = await loader_func(req, schema)
else:
data = loader_func(req, schema)
return data
maybe_async def _other_helper_func(self, ...):
# other magic -- strip the await in synchronous calls
return await self._load_location_data(...)
def public_func(self, ...):
return self._other_helper_func(...)
async def async_public_func(self, ...):
return await self._other_helper_func.call_async(...)
# why limit 'maybe_async' to internal methods?
# if it's part of the lanaguage, we also get to avoid the split in public
maybe_async def alternative_public_func(self, ...): ...
I’m aware that some of this could be done with code generation. However, maintaining maybe_async
codegen would be quite difficult for any individual library maintainer. Certainly harder than finding ways of sharing code between my own internal sync and async variants of the same set of functions.
Conclusion and final question
Is there a solution which can be written to do the above (obviously with less syntactic sugar) in the language today? Or would this require language changes as I think it would?
The goal is to improve library maintenance. So adding runtime dependencies on other pypi packages or very complex solutions don’t really solve it.
Are there known techniques for doing code-sharing between the two paths which make this problem less severe? Perhaps some clever method of passing around and chaining calls on object which may be awaitable?