From 13da50949751de6683b57221c2ab5f1fdce8eb50 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 13 Mar 2024 16:31:00 +0100 Subject: [PATCH 1/7] chore(asyncio): replace `get_event_loop()` -> `get_running_loop()` where applicable --- tests/asgi/test_scope.py | 6 +++--- tests/dump_asgi.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/asgi/test_scope.py b/tests/asgi/test_scope.py index bb60ed0e7..e368f6576 100644 --- a/tests/asgi/test_scope.py +++ b/tests/asgi/test_scope.py @@ -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) ) @@ -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) ) @@ -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) ) diff --git a/tests/dump_asgi.py b/tests/dump_asgi.py index 0742a3ca0..0dfdb4b0a 100644 --- a/tests/dump_asgi.py +++ b/tests/dump_asgi.py @@ -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()) From dd9210ae060cff13305d4fde59f641f57339fd05 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 20 Mar 2024 17:07:00 +0100 Subject: [PATCH 2/7] chore(sync): use `asyncio.Runner` for `async_to_sync()` on py311+ --- falcon/util/sync.py | 49 ++++++++++++++++++++++++++++++--------------- requirements/tests | 3 ++- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/falcon/util/sync.py b/falcon/util/sync.py index db9e21e37..8065f0c51 100644 --- a/falcon/util/sync.py +++ b/falcon/util/sync.py @@ -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 @@ -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: @@ -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. @@ -214,17 +236,12 @@ 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)) + # NOTE(vytas): Sometimes our runner's loop can get picked and consumed by + # other utilities and test methods. If that happens, recreate the runner. + global _runner + if _runner.get_loop().is_closed(): + _runner = _runner_cls() + return _runner.run(coroutine(*args, **kwargs)) def runs_sync(coroutine: Callable[..., Awaitable[Result]]) -> Callable[..., Result]: diff --git a/requirements/tests b/requirements/tests index 19b34bcd3..3f13db04f 100644 --- a/requirements/tests +++ b/requirements/tests @@ -11,7 +11,8 @@ testtools; python_version < '3.10' pytest-asyncio < 0.22.0 aiofiles httpx -uvicorn >= 0.17.0 +# TODO(vytas): Investigate: uvicorn 0.29.0 starts but then catches SIGTERM (?) +uvicorn < 0.29.0 websockets # Handler Specific From 6141517f9ff89d685bfa9f8ad7bfcea98f176542 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 20 Mar 2024 19:29:20 +0100 Subject: [PATCH 3/7] chore(sync): exempt a line from coverage as it can only be hit on 3.11+ --- falcon/util/sync.py | 5 +++-- pyproject.toml | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/falcon/util/sync.py b/falcon/util/sync.py index 8065f0c51..38b2bd5d1 100644 --- a/falcon/util/sync.py +++ b/falcon/util/sync.py @@ -236,11 +236,12 @@ def async_to_sync( Keyword Args: **kwargs: Additional args are passed through to the coroutine function. """ + 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. - global _runner if _runner.get_loop().is_closed(): - _runner = _runner_cls() + # NOTE(vytas): This condition is never hit on _DummyRunner. + _runner = _runner_cls() # pragma: nocover return _runner.run(coroutine(*args, **kwargs)) diff --git a/pyproject.toml b/pyproject.toml index ad445ce55..5ed0c5fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From ef68b8f98378527d6bc84faed94f39e3bfe932e2 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 20 Mar 2024 20:45:01 +0100 Subject: [PATCH 4/7] chore(tests/asgi): adapt to Uvicorn now propagating signals to retcode --- requirements/tests | 3 +-- tests/asgi/test_asgi_servers.py | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/requirements/tests b/requirements/tests index 3f13db04f..19b34bcd3 100644 --- a/requirements/tests +++ b/requirements/tests @@ -11,8 +11,7 @@ testtools; python_version < '3.10' pytest-asyncio < 0.22.0 aiofiles httpx -# TODO(vytas): Investigate: uvicorn 0.29.0 starts but then catches SIGTERM (?) -uvicorn < 0.29.0 +uvicorn >= 0.17.0 websockets # Handler Specific diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index 26f51ad0c..234458b68 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -4,6 +4,7 @@ import os import platform import random +import signal import subprocess import sys import time @@ -620,7 +621,10 @@ 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 + assert server.returncode in (0, -signal.SIGTERM) break From 06107060d0d547bc7e80d6c6195d59275629580f Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 20 Mar 2024 21:00:15 +0100 Subject: [PATCH 5/7] chore(tests/asgi): do not check ASGI server retcode on Windows --- tests/asgi/test_asgi_servers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index 234458b68..d8111d364 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -624,7 +624,10 @@ def server_base_url(request): # 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 - assert server.returncode in (0, -signal.SIGTERM) + # 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 From b9aac01b0701133a20f06dbf242058868b1adf05 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 20 Mar 2024 23:35:19 +0100 Subject: [PATCH 6/7] chore(tests/asgi): check for a M$ Windows specific exit code constant --- tests/asgi/test_asgi_servers.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index d8111d364..321e41f96 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -28,7 +28,9 @@ _SERVER_HOST = '127.0.0.1' _SIZE_1_KB = 1024 _SIZE_1_MB = _SIZE_1_KB**2 - +# NOTE(vytas): Windows specific: {Application Exit by CTRL+C}. +# The application terminated as a result of a CTRL+C. +_STATUS_CONTROL_C_EXIT = 0xC000013A _REQUEST_TIMEOUT = 10 @@ -624,10 +626,7 @@ def server_base_url(request): # 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) + assert server.returncode in (0, -signal.SIGTERM, _STATUS_CONTROL_C_EXIT) break From 8c1e0afcadde73ac42d32be0e12d54e9e0b23645 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 21 Mar 2024 14:15:48 +0100 Subject: [PATCH 7/7] chore(sync): use a nicer pattern to get the active runner --- falcon/util/sync.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/falcon/util/sync.py b/falcon/util/sync.py index 38b2bd5d1..96d05c058 100644 --- a/falcon/util/sync.py +++ b/falcon/util/sync.py @@ -40,9 +40,25 @@ def close(self) -> None: # pragma: nocover pass +class _ActiveRunner: + def __init__(self, runner_cls: type): + self._runner_cls = runner_cls + self._runner = runner_cls() + + # TODO(vytas): This typing is wrong on py311+, but mypy accepts it. + # It doesn't, OTOH, accept any of my ostensibly valid attempts to + # describe it. + def __call__(self) -> _DummyRunner: + # 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 self._runner.get_loop().is_closed(): + # NOTE(vytas): This condition is never hit on _DummyRunner. + self._runner = self._runner_cls() # pragma: nocover + return self._runner + + +_active_runner = _ActiveRunner(getattr(asyncio, 'Runner', _DummyRunner)) _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 @@ -236,13 +252,7 @@ def async_to_sync( Keyword Args: **kwargs: Additional args are passed through to the coroutine function. """ - 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(): - # NOTE(vytas): This condition is never hit on _DummyRunner. - _runner = _runner_cls() # pragma: nocover - return _runner.run(coroutine(*args, **kwargs)) + return _active_runner().run(coroutine(*args, **kwargs)) def runs_sync(coroutine: Callable[..., Awaitable[Result]]) -> Callable[..., Result]: