From 8f04a5ca70a2c00394127fa96140711a58510347 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Mon, 17 Jun 2024 12:12:15 +0200 Subject: [PATCH] * Drops python3.7 support. It still works afaik, but is a pain to test in CI. * Adds loose mypy run to Makefile & CI * Adds some type annotations * Adds 3.13-dev test run. It currently fails, but will pass once trio and cffi push new releases. * Adds blurb to readme on status of the project. * Bump package versions in requirements-dev.txt and requirements-dev-full.txt * Adds small tests to slightly bump code coverage --- .github/workflows/ci.yml | 7 +- Makefile | 5 +- README.md | 4 + requirements-dev-full.txt | 151 +++++++++++++++++++++----------------- requirements-dev.txt | 51 +++++++------ requirements-extras.in | 1 + setup.py | 3 +- tests/test_connection.py | 20 ++++- trio_websocket/_impl.py | 57 +++++++++----- 9 files changed, 181 insertions(+), 118 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e88af2..84ea2de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: os: [ubuntu-latest] # latest pylint/astroid doesn't support 3.7 # python3.7 is covered in build_and_test_old_deps - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12-dev'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3 @@ -27,13 +27,14 @@ jobs: - run: pip install . -r requirements-dev-full.txt - run: make test - run: make lint + - run: make typecheck build_and_test_old_deps: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] - python-version: ['3.7'] + python-version: ['3.8'] steps: - uses: actions/checkout@v3 @@ -73,7 +74,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.12-dev'] + python-version: ['3.13-dev'] steps: - uses: actions/checkout@v3 - name: Setup Python diff --git a/Makefile b/Makefile index 2efced1..f97c321 100644 --- a/Makefile +++ b/Makefile @@ -8,11 +8,14 @@ docs: $(MAKE) -C docs html test: - $(PYTHON) -m pytest --cov=trio_websocket --no-cov-on-fail + $(PYTHON) -m pytest --cov=trio_websocket --cov-report=term-missing --no-cov-on-fail lint: $(PYTHON) -m pylint trio_websocket/ tests/ autobahn/ examples/ +typecheck: + $(PYTHON) -m mypy --explicit-package-bases trio_websocket tests autobahn examples + publish: rm -fr build dist .egg trio_websocket.egg-info ! grep -q dev trio_websocket/_version.py diff --git a/README.md b/README.md index 4b96dae..139f181 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ available here](https://trio-websocket.readthedocs.io). ![Python Versions](https://img.shields.io/pypi/pyversions/trio-websocket.svg?style=flat-square) [![Build Status](https://img.shields.io/github/actions/workflow/status/python-trio/trio-websocket/ci.yml)](https://github.com/python-trio/trio-websocket/actions/workflows/ci.yml) [![Read the Docs](https://img.shields.io/readthedocs/trio-websocket.svg)](https://trio-websocket.readthedocs.io) +[![Join chatroom](https://img.shields.io/badge/chat-join%20now-blue.svg)](https://gitter.im/python-trio/general) + +## Status +**This project is on life-support maintenance.** If you're interested in helping to maintain and contribute, please reach out on [Gitter](https://gitter.im/python-trio/general) or contribute in issues and pull requests. ## Alternatives diff --git a/requirements-dev-full.txt b/requirements-dev-full.txt index 6ad3f76..a55a487 100644 --- a/requirements-dev-full.txt +++ b/requirements-dev-full.txt @@ -6,155 +6,175 @@ # alabaster==0.7.13 # via sphinx -astroid==3.0.0 +astroid==3.2.2 # via pylint -async-generator==1.10 - # via trio -attrs==22.2.0 +attrs==23.2.0 # via # -r requirements-dev.in # outcome # trio -babel==2.12.1 +babel==2.15.0 # via sphinx -bleach==6.0.0 - # via readme-renderer -build==0.10.0 +backports-tarfile==1.2.0 + # via jaraco-context +build==1.2.1 # via pip-tools -certifi==2022.12.7 +certifi==2024.6.2 # via requests -cffi==1.15.1 +cffi==1.16.0 # via cryptography -charset-normalizer==3.1.0 +charset-normalizer==3.3.2 # via requests -click==8.1.3 +click==8.1.7 # via pip-tools -coverage[toml]==7.2.1 +coverage[toml]==7.5.3 # via pytest-cov -cryptography==39.0.2 - # via trustme -dill==0.3.7 +cryptography==42.0.8 + # via + # secretstorage + # trustme +dill==0.3.8 # via pylint -docutils==0.18.1 +docutils==0.20.1 # via # readme-renderer # sphinx # sphinx-rtd-theme -exceptiongroup==1.1.3 ; python_version < "3.11" +exceptiongroup==1.2.1 ; python_version < "3.11" # via # pytest # trio # trio-websocket (setup.py) h11==0.14.0 # via wsproto -idna==3.4 +idna==3.7 # via # requests # trio # trustme imagesize==1.4.1 # via sphinx -importlib-metadata==6.0.0 +importlib-metadata==7.1.0 # via + # build # keyring # sphinx # twine -importlib-resources==6.1.0 +importlib-resources==6.4.0 # via keyring iniconfig==2.0.0 # via pytest -isort==5.11.5 +isort==5.13.2 # via pylint -jaraco-classes==3.2.3 +jaraco-classes==3.4.0 + # via keyring +jaraco-context==5.3.0 # via keyring -jinja2==3.1.2 +jaraco-functools==4.0.1 + # via keyring +jeepney==0.8.0 + # via + # keyring + # secretstorage +jinja2==3.1.4 # via sphinx -keyring==23.13.1 +keyring==25.2.1 # via twine -markdown-it-py==2.2.0 +markdown-it-py==3.0.0 # via rich -markupsafe==2.1.2 +markupsafe==2.1.5 # via jinja2 mccabe==0.7.0 # via pylint mdurl==0.1.2 # via markdown-it-py -more-itertools==9.1.0 - # via jaraco-classes -outcome==1.2.0 +more-itertools==10.2.0 + # via + # jaraco-classes + # jaraco-functools +mypy==1.10.0 + # via -r requirements-extras.in +mypy-extensions==1.0.0 + # via mypy +nh3==0.2.17 + # via readme-renderer +outcome==1.3.0.post0 # via # pytest-trio # trio -packaging==23.0 +packaging==24.1 # via # build # pytest # sphinx -pip-tools==6.14.0 +pip-tools==7.4.1 # via -r requirements-dev.in -pkginfo==1.9.6 +pkginfo==1.11.1 # via twine -platformdirs==3.1.1 +platformdirs==4.2.2 # via pylint -pluggy==1.0.0 +pluggy==1.5.0 # via pytest -pycparser==2.21 +pycparser==2.22 # via cffi -pygments==2.14.0 +pygments==2.18.0 # via # readme-renderer # rich # sphinx -pylint==3.0.0a7 +pylint==3.2.3 # via -r requirements-extras.in -pyproject-hooks==1.0.0 - # via build -pytest==7.4.2 +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +pytest==8.2.2 # via # -r requirements-dev.in # pytest-cov # pytest-trio -pytest-cov==4.0.0 +pytest-cov==5.0.0 # via -r requirements-dev.in pytest-trio==0.8.0 # via -r requirements-dev.in -pytz==2023.3.post1 +pytz==2024.1 # via babel -readme-renderer==37.3 +readme-renderer==43.0 # via twine -requests==2.28.2 +requests==2.32.3 # via # requests-toolbelt # sphinx # twine -requests-toolbelt==0.10.1 +requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine -rich==13.3.2 +rich==13.7.1 # via twine -six==1.16.0 - # via bleach -sniffio==1.3.0 +secretstorage==3.3.3 + # via keyring +sniffio==1.3.1 # via trio snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via trio -sphinx==5.3.0 +sphinx==7.1.2 # via # -r requirements-extras.in # sphinx-rtd-theme + # sphinxcontrib-jquery # sphinxcontrib-trio -sphinx-rtd-theme==1.2.0 +sphinx-rtd-theme==2.0.0 # via -r requirements-extras.in -sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-applehelp==1.0.4 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-htmlhelp==2.0.1 # via sphinx -sphinxcontrib-jquery==2.0.0 +sphinxcontrib-jquery==4.1 # via sphinx-rtd-theme sphinxcontrib-jsmath==1.0.1 # via sphinx @@ -168,36 +188,35 @@ tomli==2.0.1 # via # build # coverage + # mypy # pip-tools # pylint - # pyproject-hooks # pytest -tomlkit==0.11.6 +tomlkit==0.12.5 # via pylint -trio==0.22.0 +trio==0.25.1 # via # pytest-trio # trio-websocket (setup.py) -trustme==0.9.0 +trustme==1.1.0 # via -r requirements-dev.in -twine==4.0.2 +twine==5.1.0 # via -r requirements-extras.in -typing-extensions==4.8.0 +typing-extensions==4.12.2 # via # astroid + # mypy # pylint # rich -urllib3==1.26.15 +urllib3==2.2.1 # via # requests # twine -webencodings==0.5.1 - # via bleach -wheel==0.38.4 +wheel==0.43.0 # via pip-tools wsproto==1.2.0 # via trio-websocket (setup.py) -zipp==3.15.0 +zipp==3.19.2 # via # importlib-metadata # importlib-resources diff --git a/requirements-dev.txt b/requirements-dev.txt index 4ad6a84..f28f625 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,62 +4,64 @@ # # pip-compile --output-file=requirements-dev.txt requirements-dev.in setup.py # -async-generator==1.10 - # via trio -attrs==22.2.0 +attrs==23.2.0 # via # -r requirements-dev.in # outcome # trio -build==0.10.0 +build==1.2.1 # via pip-tools -cffi==1.15.1 +cffi==1.16.0 # via cryptography -click==8.1.3 +click==8.1.7 # via pip-tools -coverage[toml]==7.2.1 +coverage[toml]==7.5.3 # via pytest-cov -cryptography==41.0.4 +cryptography==42.0.8 # via trustme -exceptiongroup==1.1.3 ; python_version < "3.11" +exceptiongroup==1.2.1 ; python_version < "3.11" # via # pytest # trio # trio-websocket (setup.py) h11==0.14.0 # via wsproto -idna==3.4 +idna==3.7 # via # trio # trustme +importlib-metadata==7.1.0 + # via build iniconfig==2.0.0 # via pytest -outcome==1.2.0 +outcome==1.3.0.post0 # via # pytest-trio # trio -packaging==23.0 +packaging==24.1 # via # build # pytest -pip-tools==6.14.0 +pip-tools==7.4.1 # via -r requirements-dev.in -pluggy==1.0.0 +pluggy==1.5.0 # via pytest -pycparser==2.21 +pycparser==2.22 # via cffi -pyproject-hooks==1.0.0 - # via build -pytest==7.4.2 +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +pytest==8.2.2 # via # -r requirements-dev.in # pytest-cov # pytest-trio -pytest-cov==4.0.0 +pytest-cov==5.0.0 # via -r requirements-dev.in pytest-trio==0.8.0 # via -r requirements-dev.in -sniffio==1.3.0 +sniffio==1.3.1 # via trio sortedcontainers==2.4.0 # via trio @@ -68,18 +70,19 @@ tomli==2.0.1 # build # coverage # pip-tools - # pyproject-hooks # pytest -trio==0.22.0 +trio==0.25.1 # via # pytest-trio # trio-websocket (setup.py) -trustme==0.9.0 +trustme==1.1.0 # via -r requirements-dev.in -wheel==0.38.4 +wheel==0.43.0 # via pip-tools wsproto==1.2.0 # via trio-websocket (setup.py) +zipp==3.19.2 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements-extras.in b/requirements-extras.in index 9f4d0c5..926b566 100644 --- a/requirements-extras.in +++ b/requirements-extras.in @@ -1,5 +1,6 @@ # requirements for `make lint/docs/publish` pylint +mypy sphinx sphinxcontrib-trio sphinx_rtd_theme diff --git a/setup.py b/setup.py index a5040a6..17a21f9 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,6 @@ 'Intended Audience :: Developers', 'Topic :: Software Development :: Libraries', 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', @@ -37,7 +36,7 @@ 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], - python_requires=">=3.7", + python_requires=">=3.8", keywords='websocket client server trio', packages=find_packages(exclude=['docs', 'examples', 'tests']), install_requires=[ diff --git a/tests/test_connection.py b/tests/test_connection.py index 096a172..3d45eb7 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -29,6 +29,8 @@ the server to block until the client has sent the closing handshake. In other circumstances ''' +from __future__ import annotations + from functools import partial, wraps import ssl from unittest.mock import patch @@ -44,7 +46,7 @@ try: from trio.lowlevel import current_task # pylint: disable=ungrouped-imports except ImportError: - from trio.hazmat import current_task # pylint: disable=ungrouped-imports + from trio.hazmat import current_task # type: ignore # pylint: disable=ungrouped-imports from trio_websocket import ( connect_websocket, @@ -129,8 +131,10 @@ async def wrapper(*args, **kwargs): @attr.s(hash=False, eq=False) class MemoryListener(trio.abc.Listener): closed = attr.ib(default=False) - accepted_streams = attr.ib(factory=list) - queued_streams = attr.ib(factory=lambda: trio.open_memory_channel(1)) + accepted_streams: list[ + tuple[trio.abc.SendChannel[str], trio.abc.ReceiveChannel[str]] + ] = attr.ib(factory=list) + queued_streams = attr.ib(factory=lambda: trio.open_memory_channel[str](1)) accept_hook = attr.ib(default=None) async def connect(self): @@ -290,6 +294,14 @@ async def test_client_open_invalid_url(echo_server): async with open_websocket_url('http://foo.com/bar'): pass +async def test_client_open_invalid_ssl(echo_server, nursery): + with pytest.raises(TypeError, match='`use_ssl` argument must be bool or ssl.SSLContext'): + await connect_websocket(nursery, HOST, echo_server.port, RESOURCE, use_ssl=1) + + url = f'ws://{HOST}:{echo_server.port}{RESOURCE}' + with pytest.raises(ValueError, match='^SSL context must be None for ws: URL scheme$' ): + await connect_websocket_url(nursery, url, ssl_context=ssl.SSLContext(ssl.PROTOCOL_SSLv23)) + async def test_ascii_encoded_path_is_ok(echo_server): path = '%D7%90%D7%91%D7%90?%D7%90%D7%9E%D7%90' @@ -417,7 +429,7 @@ async def handler(request): @fail_after(1) -async def test_handshake_exception_before_accept(): +async def test_handshake_exception_before_accept() -> None: ''' In #107, a request handler that throws an exception before finishing the handshake causes the task to hang. The proper behavior is to raise an exception to the nursery as soon as possible. ''' diff --git a/trio_websocket/_impl.py b/trio_websocket/_impl.py index 259f4fd..b153034 100644 --- a/trio_websocket/_impl.py +++ b/trio_websocket/_impl.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys from collections import OrderedDict from contextlib import asynccontextmanager @@ -9,7 +11,7 @@ import ssl import struct import urllib.parse -from typing import List, Optional, Union +from typing import Iterable, List, Optional, Union import trio import trio.abc @@ -70,7 +72,7 @@ def __exit__(self, ty, value, tb): if _TRIO_MULTI_ERROR: # pragma: no cover filtered_exception = trio.MultiError.filter(_ignore_cancel, value) # pylint: disable=no-member - elif isinstance(value, BaseExceptionGroup): + elif isinstance(value, BaseExceptionGroup): # pylint: disable=possibly-used-before-assignment filtered_exception = value.subgroup(lambda exc: not isinstance(exc, trio.Cancelled)) else: filtered_exception = _ignore_cancel(value) @@ -78,10 +80,19 @@ def __exit__(self, ty, value, tb): @asynccontextmanager -async def open_websocket(host, port, resource, *, use_ssl, subprotocols=None, - extra_headers=None, - message_queue_size=MESSAGE_QUEUE_SIZE, max_message_size=MAX_MESSAGE_SIZE, - connect_timeout=CONN_TIMEOUT, disconnect_timeout=CONN_TIMEOUT): +async def open_websocket( + host: str, + port: int, + resource: str, + *, + use_ssl: Union[bool, ssl.SSLContext], + subprotocols: Optional[Iterable[str]] = None, + extra_headers: Optional[list[tuple[bytes,bytes]]] = None, + message_queue_size: int = MESSAGE_QUEUE_SIZE, + max_message_size: int = MAX_MESSAGE_SIZE, + connect_timeout: float = CONN_TIMEOUT, + disconnect_timeout: float = CONN_TIMEOUT + ): ''' Open a WebSocket client connection to a host. @@ -138,7 +149,8 @@ async def open_websocket(host, port, resource, *, use_ssl, subprotocols=None, async def connect_websocket(nursery, host, port, resource, *, use_ssl, subprotocols=None, extra_headers=None, - message_queue_size=MESSAGE_QUEUE_SIZE, max_message_size=MAX_MESSAGE_SIZE): + message_queue_size=MESSAGE_QUEUE_SIZE, max_message_size=MAX_MESSAGE_SIZE + ) -> WebSocketConnection: ''' Return an open WebSocket client connection to a host. @@ -179,6 +191,7 @@ async def connect_websocket(nursery, host, port, resource, *, use_ssl, logger.debug('Connecting to ws%s://%s:%d%s', '' if ssl_context is None else 's', host, port, resource) + stream: trio.SSLStream[trio.SocketStream] | trio.SocketStream if ssl_context is None: stream = await trio.open_tcp_stream(host, port) else: @@ -684,10 +697,17 @@ class WebSocketConnection(trio.abc.AsyncResource): CONNECTION_ID = itertools.count() - def __init__(self, stream, ws_connection, *, host=None, path=None, - client_subprotocols=None, client_extra_headers=None, - message_queue_size=MESSAGE_QUEUE_SIZE, - max_message_size=MAX_MESSAGE_SIZE): + def __init__( + self, + stream: trio.SocketStream | trio.SSLStream[trio.SocketStream], + ws_connection: wsproto.WSConnection, + *, + host=None, + path=None, + client_subprotocols=None, client_extra_headers=None, + message_queue_size=MESSAGE_QUEUE_SIZE, + max_message_size=MAX_MESSAGE_SIZE + ): ''' Constructor. @@ -734,13 +754,14 @@ def __init__(self, stream, ws_connection, *, host=None, path=None, self._initial_request = None self._path = path self._subprotocol: Optional[str] = None - self._handshake_headers = tuple() + self._handshake_headers: tuple[tuple[str,str], ...] = tuple() self._reject_status = 0 - self._reject_headers = tuple() + self._reject_headers: tuple[tuple[str,str], ...] = tuple() self._reject_body = b'' - self._send_channel, self._recv_channel = trio.open_memory_channel( - message_queue_size) - self._pings = OrderedDict() + self._send_channel, self._recv_channel = trio.open_memory_channel[ + Union[bytes, str] + ](message_queue_size) + self._pings: OrderedDict[bytes, trio.Event] = OrderedDict() # Set when the server has received a connection request event. This # future is never set on client connections. self._connection_proposal = Future() @@ -892,7 +913,7 @@ async def get_message(self): raise ConnectionClosed(self._close_reason) from None return message - async def ping(self, payload=None): + async def ping(self, payload: bytes|None=None): ''' Send WebSocket ping to remote endpoint and wait for a correspoding pong. @@ -915,7 +936,7 @@ async def ping(self, payload=None): if self._close_reason: raise ConnectionClosed(self._close_reason) if payload in self._pings: - raise ValueError(f'Payload value {payload} is already in flight.') + raise ValueError(f'Payload value {payload!r} is already in flight.') if payload is None: payload = struct.pack('!I', random.getrandbits(32)) event = trio.Event()