diff --git a/.github/workflows/mintest.yaml b/.github/workflows/mintest.yaml index 6fbe2c640..cf2ff0d78 100644 --- a/.github/workflows/mintest.yaml +++ b/.github/workflows/mintest.yaml @@ -18,6 +18,7 @@ jobs: - "3.8" - "3.10" - "3.11" + - "3.12" steps: - name: Checkout repo @@ -31,7 +32,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -U tox + pip install -U setuptools tox wheel python --version pip --version tox --version diff --git a/docs/conf.py b/docs/conf.py index 4778378ee..1b4b4ecb9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -88,7 +88,7 @@ # |version| and |release|, also used in various other places throughout the # built documents. -cfg = configparser.SafeConfigParser() +cfg = configparser.ConfigParser() cfg.read('../setup.cfg') tag = cfg.get('egg_info', 'tag_build') diff --git a/docs/ext/rfc.py b/docs/ext/rfc.py index 5288c43a3..608f22d96 100644 --- a/docs/ext/rfc.py +++ b/docs/ext/rfc.py @@ -23,7 +23,7 @@ import re -RFC_PATTERN = re.compile('RFC (\d{4}), Section ([\d\.]+)') +RFC_PATTERN = re.compile(r'RFC (\d{4}), Section ([\d\.]+)') def _render_section(section_number, rfc_number): diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index e7db3d5fa..db3700b2d 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -530,12 +530,14 @@ class WebSocketOptions: __slots__ = ['error_close_code', 'max_receive_queue', 'media_handlers'] - def __init__(self): + def __init__(self) -> None: try: import msgpack except ImportError: msgpack = None + bin_handler: media.BinaryBaseHandlerWS + if msgpack: bin_handler = media.MessagePackHandlerWS() else: diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py index bd3898d3c..0186e0aee 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -6,6 +6,7 @@ from falcon.constants import MEDIA_MULTIPART from falcon.constants import MEDIA_URLENCODED from falcon.constants import PYPY +from falcon.media.base import BinaryBaseHandlerWS from falcon.media.json import JSONHandler from falcon.media.multipart import MultipartFormHandler from falcon.media.multipart import MultipartParseOptions @@ -15,7 +16,7 @@ from falcon.vendor import mimeparse -class MissingDependencyHandler: +class MissingDependencyHandler(BinaryBaseHandlerWS): """Placeholder handler that always raises an error. This handler is used by the framework for media types that require an diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 3388ec3f0..00959495a 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -402,6 +402,8 @@ class ASGIWebSocketSimulator: ``None`` if the connection has not been accepted. """ + _DEFAULT_WAIT_READY_TIMEOUT = 5 + def __init__(self): self.__msgpack = None @@ -435,7 +437,7 @@ def subprotocol(self) -> str: def headers(self) -> Iterable[Iterable[bytes]]: return self._accepted_headers - async def wait_ready(self, timeout: Optional[int] = 5): + async def wait_ready(self, timeout: Optional[int] = None): """Wait until the connection has been accepted or denied. This coroutine can be awaited in order to pause execution until the @@ -447,16 +449,17 @@ async def wait_ready(self, timeout: Optional[int] = 5): raising an error (default: ``5``). """ + timeout = timeout or self._DEFAULT_WAIT_READY_TIMEOUT + try: await asyncio.wait_for(self._event_handshake_complete.wait(), timeout) except asyncio.TimeoutError: msg = ( - 'Timed out after waiting {} seconds for ' - 'the WebSocket handshake to complete. Check the ' - 'on_websocket responder and ' - 'any middleware for any conditions that may be stalling the ' - 'request flow.' - ).format(timeout) + f'Timed out after waiting {timeout} seconds for the WebSocket ' + f'handshake to complete. Check the on_websocket responder and ' + f'any middleware for any conditions that may be stalling the ' + f'request flow.' + ) raise asyncio.TimeoutError(msg) self._require_accepted() diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index 75b976211..f45ea9758 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -10,6 +10,7 @@ from falcon import media, testing from falcon.asgi import App from falcon.asgi.ws import _WebSocketState as ServerWebSocketState +from falcon.asgi.ws import WebSocket from falcon.asgi.ws import WebSocketOptions from falcon.testing.helpers import _WebSocketState as ClientWebSocketState @@ -1088,6 +1089,34 @@ class Resource: event = await ws._emit() +@pytest.mark.asyncio +async def test_ws_responder_never_ready(conductor, monkeypatch): + async def noop_close(obj, code=None): + pass + + class SleepyResource: + async def on_websocket(self, req, ws): + for i in range(10): + await asyncio.sleep(0.001) + + conductor.app.add_route('/', SleepyResource()) + + # NOTE(vytas): It seems that it is hard to impossible to hit the second + # `await ready_waiter` of the _WSContextManager on CPython 3.12 due to + # different async code optimizations, so we mock away WebSocket.close. + monkeypatch.setattr(WebSocket, 'close', noop_close) + + # NOTE(vytas): Shorten the timeout so that we do not wait for 5 seconds. + monkeypatch.setattr( + testing.ASGIWebSocketSimulator, '_DEFAULT_WAIT_READY_TIMEOUT', 0.5 + ) + + async with conductor as c: + with pytest.raises(asyncio.TimeoutError): + async with c.simulate_ws(): + pass + + @pytest.mark.skipif(msgpack, reason='test requires msgpack lib to be missing') def test_msgpack_missing():