I see red functions and I want them painted black
It’s no solution, this is just a dirty hack
Here’s a solution using a decorator. It’s something of a hack in that it takes a deep dive into how asynchronous functions and generators are actually implemented in Python; those mechanics are documented in PEP 492 and PEP 525, so they aren’t mere implementation details of CPython, but this still relies on the Python implementation never adding spurious suspension points, even if they are judged ‘harmless’, which is a little shaky assumption.
import functools, inspect
def generic_over_async(process_async):
def process_sync(iterable, /, *args, **kwargs):
async def iterable_async():
for item in iterable:
yield item
agen = process_async(iterable_async(), *args, **kwargs)
try:
sent = None
while True:
gen = agen.asend(sent)
try:
gen.send(None)
except StopIteration as e:
sent = yield e.value
else:
gen.throw(RuntimeError,
f"synchronously-called function '{process_async.__name__}' has blocked")
finally:
gen.close()
except StopAsyncIteration:
return
finally:
agen.aclose()
@functools.wraps(process_async)
def process(iterable, /, *args, **kwargs):
if inspect.isasyncgen(iterable):
return process_async(iterable, *args, **kwargs)
return process_sync(iterable, *args, **kwargs)
return process
The above defines a decorator that takes an asynchronous generator and adds logic that:
- when called with an asynchronous generator, it’s passed along to the decorated asynchronous generator as-is;
- when called with a regular iterable, it’s converted to an asynchronous generator and passed to the decorated asynchronous generator. The resulting asynchronous generator is then manually driven to completion, yielding all generated values on the way.
Using this decorator successfully requires that the only thing the decorated function can await on is the iterable given and other async functions that are effectively synchronous (i.e. they never actually block); otherwise the decorator will throw a RuntimeError. Ensuring that this never happens is left as an exercise for the reader.
Test case:
import asyncio
@generic_over_async
async def process(iterable):
async for i in iterable:
yield i * 2
async def blow_up(iterable):
""" Turns an iterable into an asynchronous iterable by adding dummy suspension points """
await asyncio.sleep(0)
for item in iterable:
yield item
await asyncio.sleep(0)
async def main():
print(list(process(range(5))))
print([item async for item in process(blow_up(range(5)))])
asyncio.get_event_loop().run_until_complete(main())
The above will print [0, 2, 4, 6, 8] twice.