Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(sync): use asyncio.Runner for async_to_sync() on py311+ #2216

Merged
merged 7 commits into from
Mar 21, 2024
50 changes: 34 additions & 16 deletions falcon/util/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,27 @@
'wrap_sync_to_async_unsafe',
]

Result = TypeVar('Result')


class _DummyRunner:
def run(self, coro: Awaitable[Result]) -> Result: # pragma: nocover
# NOTE(vytas): Work around get_event_loop deprecation in 3.10 by going
# via get_event_loop_policy(). This should be equivalent for
# async_to_sync's use case as it is currently impossible to invoke
# run_until_complete() from a running loop anyway.
return self.get_loop().run_until_complete(coro)

def get_loop(self) -> asyncio.AbstractEventLoop: # pragma: nocover
return asyncio.get_event_loop_policy().get_event_loop()

def close(self) -> None: # pragma: nocover
pass


_one_thread_to_rule_them_all = ThreadPoolExecutor(max_workers=1)
_runner_cls = getattr(asyncio, 'Runner', _DummyRunner)
_runner = _runner_cls()

create_task = asyncio.create_task
get_running_loop = asyncio.get_running_loop
Expand Down Expand Up @@ -190,9 +210,6 @@ def _wrap_non_coroutine_unsafe(
return wrap_sync_to_async_unsafe(func)


Result = TypeVar('Result')


def async_to_sync(
coroutine: Callable[..., Awaitable[Result]], *args: Any, **kwargs: Any
) -> Result:
Expand All @@ -204,8 +221,13 @@ def async_to_sync(
one will be created.

Warning:
This method is very inefficient and is intended primarily for testing
and prototyping.
Executing async code in this manner is inefficient since it involves
synchronization via threading primitives, and is intended primarily for
testing, prototyping or compatibility purposes.

Note:
On Python 3.11+, this function leverages a module-wide
``asyncio.Runner``.

Args:
coroutine: A coroutine function to invoke.
Expand All @@ -214,17 +236,13 @@ def async_to_sync(
Keyword Args:
**kwargs: Additional args are passed through to the coroutine function.
"""

# TODO(vytas): The canonical way of doing this for simple use cases is
# asyncio.run(), but that would be a breaking change wrt the above
# documented behaviour; breaking enough to break some of our own tests.

# NOTE(vytas): Work around get_event_loop deprecation in 3.10 by going via
# get_event_loop_policy(). This should be equivalent for async_to_sync's
# use case as it is currently impossible to invoke run_until_complete()
# from a running loop anyway.
loop = asyncio.get_event_loop_policy().get_event_loop()
return loop.run_until_complete(coroutine(*args, **kwargs))
global _runner
# NOTE(vytas): Sometimes our runner's loop can get picked and consumed by
# other utilities and test methods. If that happens, recreate the runner.
if _runner.get_loop().is_closed():
vytas7 marked this conversation as resolved.
Show resolved Hide resolved
# NOTE(vytas): This condition is never hit on _DummyRunner.
_runner = _runner_cls() # pragma: nocover
return _runner.run(coroutine(*args, **kwargs))


def runs_sync(coroutine: Callable[..., Awaitable[Result]]) -> Callable[..., Result]:
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ filterwarnings = [
"ignore:.cgi. is deprecated and slated for removal:DeprecationWarning",
"ignore:path is deprecated\\. Use files\\(\\) instead:DeprecationWarning",
"ignore:This process \\(.+\\) is multi-threaded",
"ignore:There is no current event loop",
]
testpaths = [
"tests"
Expand Down
9 changes: 8 additions & 1 deletion tests/asgi/test_asgi_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import platform
import random
import signal
import subprocess
import sys
import time
Expand Down Expand Up @@ -620,7 +621,13 @@ def server_base_url(request):

yield base_url

assert server.returncode == 0
# NOTE(vytas): Starting with 0.29.0, Uvicorn will propagate signal
# values into the return code (which is a good practice in Unix);
# see also https://github.com/encode/uvicorn/pull/1600
# TODO(vytas): Return codes are bananas on Windows, skip for now;
# is there a reliable way to know which code to expect?
if not _WIN32:
assert server.returncode in (0, -signal.SIGTERM)

break

Expand Down
6 changes: 3 additions & 3 deletions tests/asgi/test_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def test_supported_asgi_version(version, supported):
resp_event_collector = testing.ASGIResponseEventCollector()

async def task():
coro = asyncio.get_event_loop().create_task(
coro = asyncio.get_running_loop().create_task(
app(scope, req_event_emitter, resp_event_collector)
)

Expand Down Expand Up @@ -142,7 +142,7 @@ def test_lifespan_scope_default_version():
scope = {'type': 'lifespan'}

async def t():
t = asyncio.get_event_loop().create_task(
t = asyncio.get_running_loop().create_task(
app(scope, req_event_emitter, resp_event_collector)
)

Expand Down Expand Up @@ -196,7 +196,7 @@ def test_lifespan_scope_version(spec_version, supported):
return

async def t():
t = asyncio.get_event_loop().create_task(
t = asyncio.get_running_loop().create_task(
app(scope, req_event_emitter, resp_event_collector)
)

Expand Down
2 changes: 1 addition & 1 deletion tests/dump_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ async def app(scope, receive, send):
}
)

loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
loop.create_task(_say_hi())
Loading