diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ab9c4c72..f6a77bce0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,7 +55,6 @@ jobs: strategy: matrix: python: - - "3.9" - "3.10" - "3.11" - "3.12" diff --git a/docs/intro/index.rst b/docs/intro/index.rst index d6f8fb9e0..6b783f90c 100644 --- a/docs/intro/index.rst +++ b/docs/intro/index.rst @@ -6,7 +6,7 @@ Getting started Requirements ------------ -websockets requires Python ≥ 3.9. +websockets requires Python ≥ 3.10. .. admonition:: Use the most recent Python release :class: tip diff --git a/docs/project/changelog.rst b/docs/project/changelog.rst index 5821cbc54..5bab483ec 100644 --- a/docs/project/changelog.rst +++ b/docs/project/changelog.rst @@ -25,13 +25,21 @@ fixing regressions shortly after a release. Only documented APIs are public. Undocumented, private APIs may change without notice. -.. _15.1: +.. _16.0: -15.1 +16.0 ---- *In development* +Backwards-incompatible changes +.............................. + +.. admonition:: websockets 16.0 requires Python ≥ 3.10. + :class: tip + + websockets 15.0 is the last version supporting Python 3.9. + Improvements ............ diff --git a/docs/topics/logging.rst b/docs/topics/logging.rst index 2eedd32a4..dad63433e 100644 --- a/docs/topics/logging.rst +++ b/docs/topics/logging.rst @@ -133,8 +133,7 @@ Here's how to include them in logs, assuming they're in the async with serve( ..., - # Python < 3.10 requires passing None as the second argument. - logger=LoggerAdapter(logging.getLogger("websockets.server"), None), + logger=LoggerAdapter(logging.getLogger("websockets.server")), ): ... @@ -176,8 +175,7 @@ a :class:`~logging.LoggerAdapter`:: async with serve( ..., - # Python < 3.10 requires passing None as the second argument. - logger=LoggerAdapter(logging.getLogger("websockets.server"), None), + logger=LoggerAdapter(logging.getLogger("websockets.server")), ): ... diff --git a/example/deployment/kubernetes/Dockerfile b/example/deployment/kubernetes/Dockerfile index 83ed8722c..58f61af0d 100644 --- a/example/deployment/kubernetes/Dockerfile +++ b/example/deployment/kubernetes/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-alpine +FROM python:3.13-alpine RUN pip install websockets diff --git a/pyproject.toml b/pyproject.toml index de1b1c113..d9f31b8c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "websockets" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { text = "BSD-3-Clause" } authors = [ { name = "Aymeric Augustin", email = "aymeric.augustin@m4x.org" }, @@ -19,7 +19,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/src/websockets/asyncio/client.py b/src/websockets/asyncio/client.py index 63cd2be2e..85878e57a 100644 --- a/src/websockets/asyncio/client.py +++ b/src/websockets/asyncio/client.py @@ -579,7 +579,7 @@ async def __await_impl__(self) -> ClientConnection: # Re-raise exception with an informative error message. raise TimeoutError("timed out during opening handshake") from exc - # ... = yield from connect(...) - remove when dropping Python < 3.10 + # ... = yield from connect(...) - remove when dropping Python < 3.11 __iter__ = __await__ @@ -629,8 +629,7 @@ async def __aiter__(self) -> AsyncIterator[ClientConnection]: self.logger.info( "connect failed; reconnecting in %.1f seconds: %s", delay, - # Remove first argument when dropping Python 3.9. - traceback.format_exception_only(type(exc), exc)[0].strip(), + traceback.format_exception_only(exc)[0].strip(), ) await asyncio.sleep(delay) continue diff --git a/src/websockets/asyncio/connection.py b/src/websockets/asyncio/connection.py index 61c300d63..6acced0c1 100644 --- a/src/websockets/asyncio/connection.py +++ b/src/websockets/asyncio/connection.py @@ -1224,11 +1224,7 @@ def broadcast( else: connection.logger.warning( "skipped broadcast: failed to write message: %s", - traceback.format_exception_only( - # Remove first argument when dropping Python 3.9. - type(write_exception), - write_exception, - )[0].strip(), + traceback.format_exception_only(write_exception)[0].strip(), ) if raise_exceptions and exceptions: diff --git a/src/websockets/asyncio/server.py b/src/websockets/asyncio/server.py index 745869299..4eae2b980 100644 --- a/src/websockets/asyncio/server.py +++ b/src/websockets/asyncio/server.py @@ -834,7 +834,7 @@ async def __await_impl__(self) -> Server: self.server.wrap(server) return self.server - # ... = yield from serve(...) - remove when dropping Python < 3.10 + # ... = yield from serve(...) - remove when dropping Python < 3.11 __iter__ = __await__ diff --git a/src/websockets/datastructures.py b/src/websockets/datastructures.py index 3c5dcbe9a..6d5d66d9a 100644 --- a/src/websockets/datastructures.py +++ b/src/websockets/datastructures.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping, MutableMapping -from typing import Any, Protocol, Union +from typing import Any, Protocol __all__ = [ @@ -171,13 +171,9 @@ def keys(self) -> Iterable[str]: ... def __getitem__(self, key: str) -> str: ... -# Change to Headers | Mapping[str, str] | ... when dropping Python < 3.10. -HeadersLike = Union[ - Headers, - Mapping[str, str], - Iterable[tuple[str, str]], - SupportsKeysAndGetItem, -] +HeadersLike = ( + Headers | Mapping[str, str] | Iterable[tuple[str, str]] | SupportsKeysAndGetItem +) """ Types accepted where :class:`Headers` is expected. diff --git a/src/websockets/frames.py b/src/websockets/frames.py index ab0869d01..3a7077b66 100644 --- a/src/websockets/frames.py +++ b/src/websockets/frames.py @@ -7,7 +7,7 @@ import secrets import struct from collections.abc import Generator, Sequence -from typing import Callable, Union +from typing import Callable from .exceptions import PayloadTooBig, ProtocolError @@ -140,7 +140,7 @@ class Frame: """ opcode: Opcode - data: Union[bytes, bytearray, memoryview] + data: bytes | bytearray | memoryview fin: bool = True rsv1: bool = False rsv2: bool = False diff --git a/src/websockets/legacy/client.py b/src/websockets/legacy/client.py index 29141f39a..575c84519 100644 --- a/src/websockets/legacy/client.py +++ b/src/websockets/legacy/client.py @@ -607,16 +607,14 @@ async def __aiter__(self) -> AsyncIterator[WebSocketClientProtocol]: self.logger.info( "connect failed; reconnecting in %.1f seconds: %s", initial_delay, - # Remove first argument when dropping Python 3.9. - traceback.format_exception_only(type(exc), exc)[0].strip(), + traceback.format_exception_only(exc)[0].strip(), ) await asyncio.sleep(initial_delay) else: self.logger.info( "connect failed again; retrying in %d seconds: %s", int(backoff_delay), - # Remove first argument when dropping Python 3.9. - traceback.format_exception_only(type(exc), exc)[0].strip(), + traceback.format_exception_only(exc)[0].strip(), ) await asyncio.sleep(int(backoff_delay)) # Increase delay with truncated exponential backoff. @@ -673,7 +671,7 @@ async def __await_impl__(self) -> WebSocketClientProtocol: else: raise SecurityError("too many redirects") - # ... = yield from connect(...) - remove when dropping Python < 3.10 + # ... = yield from connect(...) - remove when dropping Python < 3.11 __iter__ = __await__ diff --git a/src/websockets/legacy/protocol.py b/src/websockets/legacy/protocol.py index db126c01e..1d604677a 100644 --- a/src/websockets/legacy/protocol.py +++ b/src/websockets/legacy/protocol.py @@ -230,8 +230,6 @@ def __init__( self._paused = False self._drain_waiter: asyncio.Future[None] | None = None - self._drain_lock = asyncio.Lock() - # This class implements the data transfer and closing handshake, which # are shared between the client-side and the server-side. # Subclasses implement the opening handshake and, on success, execute @@ -1166,12 +1164,8 @@ def write_frame_sync(self, fin: bool, opcode: int, data: bytes) -> None: async def drain(self) -> None: try: - # drain() cannot be called concurrently by multiple coroutines. - # See https://github.com/python/cpython/issues/74116 for details. - # This workaround can be removed when dropping Python < 3.10. - async with self._drain_lock: - # Handle flow control automatically. - await self._drain() + # Handle flow control automatically. + await self._drain() except ConnectionError: # Terminate the connection if the socket died. self.fail_connection() @@ -1626,11 +1620,7 @@ def broadcast( else: websocket.logger.warning( "skipped broadcast: failed to write message: %s", - traceback.format_exception_only( - # Remove first argument when dropping Python 3.9. - type(write_exception), - write_exception, - )[0].strip(), + traceback.format_exception_only(write_exception)[0].strip(), ) if raise_exceptions and exceptions: diff --git a/src/websockets/legacy/server.py b/src/websockets/legacy/server.py index f9d57cb99..d2a69c716 100644 --- a/src/websockets/legacy/server.py +++ b/src/websockets/legacy/server.py @@ -10,7 +10,7 @@ import warnings from collections.abc import Awaitable, Generator, Iterable, Sequence from types import TracebackType -from typing import Any, Callable, Union, cast +from typing import Any, Callable, cast from ..asyncio.compatibility import asyncio_timeout from ..datastructures import Headers, HeadersLike, MultipleValuesError @@ -48,8 +48,7 @@ ] -# Change to HeadersLike | ... when dropping Python < 3.10. -HeadersLikeOrCallable = Union[HeadersLike, Callable[[str, Headers], HeadersLike]] +HeadersLikeOrCallable = HeadersLike | Callable[[str, Headers], HeadersLike] HTTPResponse = tuple[StatusLike, HeadersLike, bytes] @@ -706,7 +705,8 @@ def wrap(self, server: asyncio.base_events.Server) -> None: self.logger.info("server listening on %s", name) # Initialized here because we need a reference to the event loop. - # This should be moved back to __init__ when dropping Python < 3.10. + # This could be moved back to __init__ now that Python < 3.10 isn't + # supported anymore, but I'm not taking that risk in legacy code. self.closed_waiter = server.get_loop().create_future() def register(self, protocol: WebSocketServerProtocol) -> None: @@ -1124,7 +1124,7 @@ async def __await_impl__(self) -> WebSocketServer: self.ws_server.wrap(server) return self.ws_server - # yield from serve(...) - remove when dropping Python < 3.10 + # yield from serve(...) - remove when dropping Python < 3.11 __iter__ = __await__ diff --git a/src/websockets/protocol.py b/src/websockets/protocol.py index c2522b8a5..4054941b9 100644 --- a/src/websockets/protocol.py +++ b/src/websockets/protocol.py @@ -4,7 +4,6 @@ import logging import uuid from collections.abc import Generator -from typing import Union from .exceptions import ( ConnectionClosed, @@ -39,8 +38,7 @@ "SEND_EOF", ] -# Change to Request | Response | Frame when dropping Python < 3.10. -Event = Union[Request, Response, Frame] +Event = Request | Response | Frame """Events that :meth:`~Protocol.events_received` may return.""" diff --git a/src/websockets/typing.py b/src/websockets/typing.py index ab7ddd33e..93636e1c9 100644 --- a/src/websockets/typing.py +++ b/src/websockets/typing.py @@ -2,7 +2,7 @@ import http import logging -from typing import TYPE_CHECKING, Any, NewType, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, NewType, Sequence __all__ = [ @@ -18,8 +18,7 @@ # Public types used in the signature of public APIs -# Change to str | bytes when dropping Python < 3.10. -Data = Union[str, bytes] +Data = str | bytes """Types supported in a WebSocket message: :class:`str` for a Text_ frame, :class:`bytes` for a Binary_. @@ -29,17 +28,15 @@ """ -# Change to logging.Logger | ... when dropping Python < 3.10. if TYPE_CHECKING: - LoggerLike = Union[logging.Logger, logging.LoggerAdapter[Any]] + LoggerLike = logging.Logger | logging.LoggerAdapter[Any] """Types accepted where a :class:`~logging.Logger` is expected.""" else: # remove this branch when dropping support for Python < 3.11 - LoggerLike = Union[logging.Logger, logging.LoggerAdapter] + LoggerLike = logging.Logger | logging.LoggerAdapter """Types accepted where a :class:`~logging.Logger` is expected.""" -# Change to http.HTTPStatus | int when dropping Python < 3.10. -StatusLike = Union[http.HTTPStatus, int] +StatusLike = http.HTTPStatus | int """ Types accepted where an :class:`~http.HTTPStatus` is expected.""" @@ -55,8 +52,7 @@ ExtensionName = NewType("ExtensionName", str) """Name of a WebSocket extension.""" -# Change to tuple[str, str | None] when dropping Python < 3.10. -ExtensionParameter = tuple[str, Optional[str]] +ExtensionParameter = tuple[str, str | None] """Parameter of a WebSocket extension.""" diff --git a/tests/asyncio/test_client.py b/tests/asyncio/test_client.py index 465ea2bdb..a83074ae8 100644 --- a/tests/asyncio/test_client.py +++ b/tests/asyncio/test_client.py @@ -30,8 +30,6 @@ from .server import args, get_host_port, get_uri, handler -# Decorate tests that need it with @short_backoff_delay() instead of using it as -# a context manager when dropping support for Python < 3.10. @contextlib.asynccontextmanager async def short_backoff_delay(): defaults = backoff.__defaults__ @@ -47,8 +45,6 @@ async def short_backoff_delay(): backoff.__defaults__ = defaults -# Decorate tests that need it with @few_redirects() instead of using it as a -# context manager when dropping support for Python < 3.10. @contextlib.asynccontextmanager async def few_redirects(): from websockets.asyncio import client @@ -162,6 +158,7 @@ def create_connection(*args, **kwargs): ) as client: self.assertTrue(client.create_connection_ran) + @short_backoff_delay() async def test_reconnect(self): """Client reconnects to server.""" iterations = 0 @@ -183,10 +180,9 @@ async def process_request(connection, request): async with serve(*args, process_request=process_request) as server: with self.assertRaises(InvalidStatus) as raised: - async with short_backoff_delay(): - async for client in connect(get_uri(server), open_timeout=3 * MS): - self.assertEqual(client.protocol.state.name, "OPEN") - successful += 1 + async for client in connect(get_uri(server), open_timeout=3 * MS): + self.assertEqual(client.protocol.state.name, "OPEN") + successful += 1 self.assertEqual( str(raised.exception), @@ -195,6 +191,7 @@ async def process_request(connection, request): self.assertEqual(iterations, 6) self.assertEqual(successful, 2) + @short_backoff_delay() async def test_reconnect_with_custom_process_exception(self): """Client runs process_exception to tell if errors are retryable or fatal.""" iteration = 0 @@ -216,11 +213,10 @@ def process_exception(exc): async with serve(*args, process_request=process_request) as server: with self.assertRaises(Exception) as raised: - async with short_backoff_delay(): - async for _ in connect( - get_uri(server), process_exception=process_exception - ): - self.fail("did not raise") + async for _ in connect( + get_uri(server), process_exception=process_exception + ): + self.fail("did not raise") self.assertEqual(iteration, 2) self.assertEqual( @@ -228,6 +224,7 @@ def process_exception(exc): "🫖 💔 ☕️", ) + @short_backoff_delay() async def test_reconnect_with_custom_process_exception_raising_exception(self): """Client supports raising an exception in process_exception.""" @@ -241,11 +238,10 @@ def process_exception(exc): async with serve(*args, process_request=process_request) as server: with self.assertRaises(Exception) as raised: - async with short_backoff_delay(): - async for _ in connect( - get_uri(server), process_exception=process_exception - ): - self.fail("did not raise") + async for _ in connect( + get_uri(server), process_exception=process_exception + ): + self.fail("did not raise") self.assertEqual( str(raised.exception), @@ -279,6 +275,7 @@ def redirect(connection, request): self.assertFalse(server.connections) self.assertTrue(other_server.connections) + @few_redirects() async def test_redirect_limit(self): """Client stops following redirects after limit is reached.""" @@ -288,10 +285,9 @@ def redirect(connection, request): return response async with serve(*args, process_request=redirect) as server: - async with few_redirects(): - with self.assertRaises(SecurityError) as raised: - async with connect(get_uri(server)): - self.fail("did not raise") + with self.assertRaises(SecurityError) as raised: + async with connect(get_uri(server)): + self.fail("did not raise") self.assertEqual( str(raised.exception), diff --git a/tests/asyncio/test_connection.py b/tests/asyncio/test_connection.py index 668f55cbd..6cad971c7 100644 --- a/tests/asyncio/test_connection.py +++ b/tests/asyncio/test_connection.py @@ -19,7 +19,7 @@ from websockets.protocol import CLIENT, SERVER, Protocol, State from ..protocol import RecordingProtocol -from ..utils import MS, AssertNoLogsMixin +from ..utils import MS from .connection import InterceptingConnection from .utils import alist @@ -28,7 +28,7 @@ # All tests run on the client side and the server side to validate this. -class ClientConnectionTests(AssertNoLogsMixin, unittest.IsolatedAsyncioTestCase): +class ClientConnectionTests(unittest.IsolatedAsyncioTestCase): LOCAL = CLIENT REMOTE = SERVER @@ -790,8 +790,7 @@ async def test_close_timeout_waiting_for_connection_closed(self): exc = raised.exception self.assertEqual(str(exc), "sent 1000 (OK); then received 1000 (OK)") - # Remove socket.timeout when dropping Python < 3.10. - self.assertIsInstance(exc.__cause__, (socket.timeout, TimeoutError)) + self.assertIsInstance(exc.__cause__, TimeoutError) async def test_close_preserves_queued_messages(self): """close preserves messages buffered in the assembler.""" diff --git a/tests/asyncio/test_server.py b/tests/asyncio/test_server.py index 6adfff8e9..903057391 100644 --- a/tests/asyncio/test_server.py +++ b/tests/asyncio/test_server.py @@ -21,7 +21,6 @@ CLIENT_CONTEXT, MS, SERVER_CONTEXT, - AssertNoLogsMixin, temp_unix_socket_path, ) from .server import ( @@ -33,7 +32,7 @@ ) -class ServerTests(EvalShellMixin, AssertNoLogsMixin, unittest.IsolatedAsyncioTestCase): +class ServerTests(EvalShellMixin, unittest.IsolatedAsyncioTestCase): async def test_connection(self): """Server receives connection from client and the handshake succeeds.""" async with serve(*args) as server: diff --git a/tests/legacy/utils.py b/tests/legacy/utils.py index 1f79bb600..20b0191f8 100644 --- a/tests/legacy/utils.py +++ b/tests/legacy/utils.py @@ -3,10 +3,8 @@ import sys import unittest -from ..utils import AssertNoLogsMixin - -class AsyncioTestCase(AssertNoLogsMixin, unittest.TestCase): +class AsyncioTestCase(unittest.TestCase): """ Base class for tests that sets up an isolated event loop for each test. diff --git a/tests/sync/test_connection.py b/tests/sync/test_connection.py index a5aee35bb..07730c48c 100644 --- a/tests/sync/test_connection.py +++ b/tests/sync/test_connection.py @@ -539,8 +539,7 @@ def test_close_timeout_waiting_for_connection_closed(self): exc = raised.exception self.assertEqual(str(exc), "sent 1000 (OK); then received 1000 (OK)") - # Remove socket.timeout when dropping Python < 3.10. - self.assertIsInstance(exc.__cause__, (socket.timeout, TimeoutError)) + self.assertIsInstance(exc.__cause__, TimeoutError) def test_close_preserves_queued_messages(self): """close preserves messages buffered in the assembler.""" diff --git a/tests/utils.py b/tests/utils.py index 7932aae60..bd3bb0ed9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,11 +1,9 @@ import contextlib import email.utils -import logging import os import pathlib import platform import ssl -import sys import tempfile import time import unittest @@ -114,30 +112,6 @@ def assertDeprecationWarning(self, message): self.assertEqual(str(warning.message), message) -class AssertNoLogsMixin: - """ - Backport of assertNoLogs for Python 3.9. - - """ - - if sys.version_info[:2] < (3, 10): # pragma: no cover - - @contextlib.contextmanager - def assertNoLogs(self, logger=None, level=None): - """ - No message is logged on the given logger with at least the given level. - - """ - with self.assertLogs(logger, level) as logs: - # We want to test that no log message is emitted - # but assertLogs expects at least one log message. - logging.getLogger(logger).log(level, "dummy") - yield - - level_name = logging.getLevelName(level) - self.assertEqual(logs.output, [f"{level_name}:{logger}:dummy"]) - - @contextlib.contextmanager def temp_unix_socket_path(): with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tox.ini b/tox.ini index 9450e9714..bfd753b3d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] env_list = - py39 py310 py311 py312 @@ -46,6 +45,6 @@ deps = commands = mypy --strict src deps = - mypy + mypy<1.16.0 python-socks werkzeug