pax_global_header00006660000000000000000000000064145046636450014527gustar00rootroot0000000000000052 comment=b263098d029aeac3072e1bc20f09f0a15f5bee41 python-trio-websocket-0.11.1/000077500000000000000000000000001450466364500160675ustar00rootroot00000000000000python-trio-websocket-0.11.1/.github/000077500000000000000000000000001450466364500174275ustar00rootroot00000000000000python-trio-websocket-0.11.1/.github/workflows/000077500000000000000000000000001450466364500214645ustar00rootroot00000000000000python-trio-websocket-0.11.1/.github/workflows/ci.yml000066400000000000000000000044021450466364500226020ustar00rootroot00000000000000name: CI on: push: branches: - master pull_request: # pylint crashes on 3.12 due to https://github.com/pylint-dev/astroid/issues/2201 # see https://github.com/pylint-dev/pylint/issues/8782 jobs: build_and_test_pinned: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'requirements-dev-full.txt' - run: pip install . -r requirements-dev-full.txt - run: make test - run: make lint build_and_test_old_deps: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ['3.7'] steps: - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'requirements-dev.txt' - run: | pip install . -r requirements-dev.txt pip install trio==0.15.0 pytest-trio==0.7.0 # trio with MultiError pip install wsproto==0.14.0 # wsproto allowing message during REMOTE_CLOSING, etc. - run: make test build_and_test_pypy: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ['pypy-3.9','pypy-3.10'] steps: - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'requirements-dev.txt' - run: pip install . -r requirements-dev.txt - run: make test build_and_test_latest: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ['3.12-dev'] steps: - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - run: pip install . -r requirements-dev.in - run: make test python-trio-websocket-0.11.1/.gitignore000066400000000000000000000001431450466364500200550ustar00rootroot00000000000000.coverage .pytest_cache __pycache__ coverage.xml dist examples/fake.* trio_websocket.egg-info venv python-trio-websocket-0.11.1/CHANGELOG.md000066400000000000000000000052341450466364500177040ustar00rootroot00000000000000# Release history ## trio-websocket 0.11.1 (2023-09-26) ### Changed - remove exceptiongroup dependency for Python >= 3.11 ## trio-websocket 0.10.4 (2023-09-06) ### Fixed - fix client hang when connection lost just after remote closes ## trio-websocket 0.10.3 (2023-06-08) ### Fixed - fixed exception when installed trio package version has a suffix like `+dev` ## trio-websocket 0.10.2 (2023-03-19) ### Fixed - fixed a race condition where, just after a local-initiated close, the `closed` attribute would be `None`, and `send_message()` would be silently ignored (wsproto < 0.2.0) or leak a `LocalProtocolError` (wsproto >= 0.2.0) rather than raise `ConnectionClosed` ([#158](https://github.com/python-trio/trio-websocket/issues/158)) ## trio-websocket 0.10.1 (2023-03-18) ### Fixed - `send_message()` is changed to raise `ConnectionClosed` when a close handshake is in progress. Previously, it would silently ignore the call, which was an oversight, given that `ConnectionClosed` is defined to cover connections "closed or in the process of closing". Notably, this fixes `send_message()` leaking a wsproto `LocalProtocolError` with wsproto >= 1.2.0. ([#175](https://github.com/python-trio/trio-websocket/issues/175)) Released as a minor version increment, since code calling `send_message()` is expected to handle `ConnectionClosed` anyway. ## trio-websocket 0.10.0 (2023-03-13) ### Fixed - avoid MultiError warnings with trio >= 0.22 ### Changed - drop support for Python 3.5, 3.6 ## trio-websocket 0.9.2 (2021-02-05) ### Fixed - the server will now correctly close the TCP stream on a CloseConnection event ([#115](https://github.com/python-trio/trio-websocket/issues/115)) ## trio-websocket 0.9.1 (2020-12-06) ### Fixed - fix client open_websocket_url() when the URL path component is empty ([#148](https://github.com/python-trio/trio-websocket/issues/148)) ## trio-websocket 0.9.0 (2020-11-25) > **_NOTE:_** `wsaccel`, which was important for good performance of >`wsproto <= 0.14`, has been dropped as a trio-websocket requirement. So > ensure that your app either upgrades to `wsproto >= 0.15` or explicitly > requires `wsaccel`. ### Changed - allow dependency on recent `wsproto` versions - eliminate `yarl`, `ipaddress`, and `wsaccel` dependencies ### Fixed - avoid contributing to dropped exceptions during finalization. (See Trio issue https://github.com/python-trio/trio/issues/1559 for background.) ## trio-websocket 0.8.1 (2020-09-22) ### Fixed - reader task no longer raises unhandled exception on ClosedResourceError ([#134](https://github.com/python-trio/trio-websocket/issues/134)) - minor issues in example code, documentation, and type-hinting ## ... python-trio-websocket-0.11.1/LICENSE000066400000000000000000000020701450466364500170730ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2018 Hyperion Gray Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-trio-websocket-0.11.1/MANIFEST.in000066400000000000000000000000201450466364500176150ustar00rootroot00000000000000include LICENSE python-trio-websocket-0.11.1/Makefile000066400000000000000000000022301450466364500175240ustar00rootroot00000000000000PYTHON = python # This .PHONY line prevents make from treating the docs/ directory like a build # product: .PHONY: docs docs: $(MAKE) -C docs html test: $(PYTHON) -m pytest --cov=trio_websocket --no-cov-on-fail lint: $(PYTHON) -m pylint trio_websocket/ tests/ autobahn/ examples/ publish: rm -fr build dist .egg trio_websocket.egg-info ! grep -q dev trio_websocket/_version.py $(PYTHON) -m build twine check dist/* twine upload dist/* # requirements-dev.txt will only be regenerated when PIP_COMPILE_ARGS is not # empty, and requires installatation of pip-tools. # # To change requirements, edit setup.py and requirements-dev.in files as necessary, then: # make -W requirements-dev.{in,txt} PIP_COMPILE_ARGS="-q" # upgrade all deps: # make -W requirements-dev.{in,txt} PIP_COMPILE_ARGS="-U" # upgrade specific deps: # make -W requirements-dev.{in,txt} PIP_COMPILE_ARGS="-P foo" ifneq ($(PIP_COMPILE_ARGS),) requirements-dev-full.txt: setup.py requirements-dev.in requirements-extras.in pip-compile -q $(PIP_COMPILE_ARGS) --output-file $@ $^ requirements-dev.txt: setup.py requirements-dev.in pip-compile -q $(PIP_COMPILE_ARGS) --output-file $@ $^ endif python-trio-websocket-0.11.1/README.md000066400000000000000000000065661450466364500173630ustar00rootroot00000000000000# Trio WebSocket This library implements both server and client aspects of the [the WebSocket protocol](https://tools.ietf.org/html/rfc6455), striving for safety, correctness, and ergonomics. It is based on the [wsproto project](https://wsproto.readthedocs.io/en/latest/), which is a [Sans-IO](https://sans-io.readthedocs.io/) state machine that implements the majority of the WebSocket protocol, including framing, codecs, and events. This library handles I/O using [the Trio framework](https://trio.readthedocs.io/en/latest/). This library passes the [Autobahn Test Suite](https://github.com/crossbario/autobahn-testsuite). This README contains a brief introduction to the project. Full documentation [is available here](https://trio-websocket.readthedocs.io). [![PyPI](https://img.shields.io/pypi/v/trio-websocket.svg?style=flat-square)](https://pypi.org/project/trio-websocket/) ![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) ## Alternatives If you happen to only need a server, using Quart via the [quart-trio](https://github.com/pgjones/quart-trio) extension may suffice. While trio-websocket is more flexible, Quart covers both HTTP and WebSocket within a single framework, and serving both from the same port is straightforward. There has yet to be a performance comparison. ## Installation This library requires Python 3.7 or greater. To install from PyPI: pip install trio-websocket ## Client Example This example demonstrates how to open a WebSocket URL: ```python import trio from sys import stderr from trio_websocket import open_websocket_url async def main(): try: async with open_websocket_url('wss://echo.websocket.org') as ws: await ws.send_message('hello world!') message = await ws.get_message() print('Received message: %s' % message) except OSError as ose: print('Connection attempt failed: %s' % ose, file=stderr) trio.run(main) ``` The WebSocket context manager connects automatically before entering the block and disconnects automatically before exiting the block. The full API offers a lot of flexibility and additional options. ## Server Example A WebSocket server requires a bind address, a port, and a coroutine to handle incoming connections. This example demonstrates an "echo server" that replies to each incoming message with an identical outgoing message. ```python import trio from trio_websocket import serve_websocket, ConnectionClosed async def echo_server(request): ws = await request.accept() while True: try: message = await ws.get_message() await ws.send_message(message) except ConnectionClosed: break async def main(): await serve_websocket(echo_server, '127.0.0.1', 8000, ssl_context=None) trio.run(main) ``` The server's handler ``echo_server(…)`` receives a connection request object. This object can be used to inspect the client's request and modify the handshake, then it can be exchanged for an actual WebSocket object ``ws``. Again, the full API offers a lot of flexibility and additional options. python-trio-websocket-0.11.1/autobahn/000077500000000000000000000000001450466364500176705ustar00rootroot00000000000000python-trio-websocket-0.11.1/autobahn/client.py000066400000000000000000000062201450466364500215200ustar00rootroot00000000000000''' This test client runs against the Autobahn test server. It is based on the test_client.py in wsproto. ''' import argparse import json import logging import sys import trio from trio_websocket import open_websocket_url, ConnectionClosed AGENT = 'trio-websocket' MAX_MESSAGE_SIZE = 16 * 1024 * 1024 logging.basicConfig(level=logging.INFO) logger = logging.getLogger('client') async def get_case_count(url): url = url + '/getCaseCount' async with open_websocket_url(url) as conn: case_count = await conn.get_message() logger.info('Case count=%s', case_count) return int(case_count) async def get_case_info(url, case): url = f'{url}/getCaseInfo?case={case}' async with open_websocket_url(url) as conn: return json.loads(await conn.get_message()) async def run_case(url, case): url = f'{url}/runCase?case={case}&agent={AGENT}' try: async with open_websocket_url(url, max_message_size=MAX_MESSAGE_SIZE) as conn: while True: data = await conn.get_message() await conn.send_message(data) except ConnectionClosed: pass async def update_reports(url): url = f'{url}/updateReports?agent={AGENT}' async with open_websocket_url(url) as conn: # This command runs as soon as we connect to it, so we don't need to # send any messages. pass async def run_tests(args): logger = logging.getLogger('trio-websocket') if args.debug_cases: # Don't fetch case count when debugging a subset of test cases. It adds # noise to the debug logging. case_count = None test_cases = args.debug_cases else: case_count = await get_case_count(args.url) test_cases = list(range(1, case_count + 1)) exception_cases = [] for case in test_cases: case_id = (await get_case_info(args.url, case))['id'] if case_count: logger.info("Running test case %s (%d of %d)", case_id, case, case_count) else: logger.info("Debugging test case %s (%d)", case_id, case) logger.setLevel(logging.DEBUG) try: await run_case(args.url, case) except Exception: # pylint: disable=broad-exception-caught logger.exception(' runtime exception during test case %s (%d)', case_id, case) exception_cases.append(case_id) logger.setLevel(logging.INFO) logger.info('Updating report') await update_reports(args.url) if exception_cases: logger.error('Runtime exception in %d of %d test cases: %s', len(exception_cases), len(test_cases), exception_cases) sys.exit(1) def parse_args(): ''' Parse command line arguments. ''' parser = argparse.ArgumentParser(description='Autobahn client for' ' trio-websocket') parser.add_argument('url', help='WebSocket URL for server') # TODO: accept case ID's rather than indices parser.add_argument('debug_cases', type=int, nargs='*', help='Run' ' individual test cases with debug logging (optional)') return parser.parse_args() if __name__ == '__main__': args = parse_args() trio.run(run_tests, args) python-trio-websocket-0.11.1/autobahn/config/000077500000000000000000000000001450466364500211355ustar00rootroot00000000000000python-trio-websocket-0.11.1/autobahn/config/fuzzingclient.json000066400000000000000000000003641450466364500247260ustar00rootroot00000000000000{ "options": {"failByDrop": false}, "outdir": "./reports/servers", "servers": [{"agent": "wsproto", "url": "ws://172.17.0.1:9000", "options": {"version": 18}}], "cases": ["*"], "exclude-cases": [], "exclude-agent-cases": {} } python-trio-websocket-0.11.1/autobahn/config/fuzzingserver.json000066400000000000000000000002701450466364500247520ustar00rootroot00000000000000{ "url": "ws://127.0.0.1:9001", "outdir": "./reports/clients", "cases": ["*"], "exclude-cases": [ "12.*", "13.*" ], "exclude-agent-cases": {} } python-trio-websocket-0.11.1/autobahn/reports/000077500000000000000000000000001450466364500213665ustar00rootroot00000000000000python-trio-websocket-0.11.1/autobahn/reports/.gitignore000066400000000000000000000000021450466364500233460ustar00rootroot00000000000000* python-trio-websocket-0.11.1/autobahn/server.py000066400000000000000000000037031450466364500215530ustar00rootroot00000000000000''' This simple WebSocket server responds to text messages by reversing each message string and sending it back. It also handles ping/pong automatically and will correctly close down a connection when the client requests it. To use SSL/TLS: install the `trustme` package from PyPI and run the `generate-cert.py` script in this directory. ''' import argparse import logging import trio from trio_websocket import serve_websocket, ConnectionClosed, WebSocketRequest BIND_IP = '0.0.0.0' BIND_PORT = 9000 MAX_MESSAGE_SIZE = 16 * 1024 * 1024 logging.basicConfig() logger = logging.getLogger('client') logger.setLevel(logging.INFO) connection_count = 0 async def main(): ''' Main entry point. ''' logger.info('Starting websocket server on ws://%s:%d', BIND_IP, BIND_PORT) await serve_websocket(handler, BIND_IP, BIND_PORT, ssl_context=None, max_message_size=MAX_MESSAGE_SIZE) async def handler(request: WebSocketRequest): ''' Reverse incoming websocket messages and send them back. ''' global connection_count # pylint: disable=global-statement connection_count += 1 logger.info('Connection #%d', connection_count) ws = await request.accept() while True: try: message = await ws.get_message() await ws.send_message(message) except ConnectionClosed: break except Exception: # pylint: disable=broad-exception-caught logger.exception(' runtime exception handling connection #%d', connection_count) def parse_args(): ''' Parse command line arguments. ''' parser = argparse.ArgumentParser(description='Autobahn server for' ' trio-websocket') parser.add_argument('-d', '--debug', action='store_true', help='WebSocket URL for server') return parser.parse_args() if __name__ == '__main__': args = parse_args() if args.debug: logging.getLogger('trio-websocket').setLevel(logging.DEBUG) trio.run(main) python-trio-websocket-0.11.1/docs/000077500000000000000000000000001450466364500170175ustar00rootroot00000000000000python-trio-websocket-0.11.1/docs/.gitignore000066400000000000000000000000101450466364500207760ustar00rootroot00000000000000_build python-trio-websocket-0.11.1/docs/Makefile000066400000000000000000000011431450466364500204560ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. PYTHON = python SPHINXOPTS = SPHINXBUILD = $(PYTHON) -m sphinx SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) python-trio-websocket-0.11.1/docs/_static/000077500000000000000000000000001450466364500204455ustar00rootroot00000000000000python-trio-websocket-0.11.1/docs/_static/README.txt000066400000000000000000000001261450466364500221420ustar00rootroot00000000000000This is just a placeholder file because this project doesn't have any static assets. python-trio-websocket-0.11.1/docs/api.rst000066400000000000000000000063651450466364500203340ustar00rootroot00000000000000API === .. currentmodule:: trio_websocket In addition to the convenience functions documented in :ref:`websocket-clients` and :ref:`websocket-servers`, the API has several important classes described on this page. Requests -------- .. class:: WebSocketRequest A request object presents the client's handshake to a server handler. The server can inspect handshake properties like HTTP headers, subprotocols, etc. The server can also set some handshake properties like subprotocol. The server should call :meth:`accept` to complete the handshake and obtain a connection object. .. autoattribute:: headers .. autoattribute:: proposed_subprotocols .. autoattribute:: local .. autoattribute:: remote .. automethod:: accept .. automethod:: reject Connections ----------- .. class:: WebSocketConnection A connection object has functionality for sending and receiving messages, pinging the remote endpoint, and closing the WebSocket. .. note:: The preferred way to obtain a connection is to use one of the convenience functions described in :ref:`websocket-clients` or :ref:`websocket-servers`. Instantiating a connection instance directly is tricky and is not recommended. This object has properties that expose connection metadata. .. autoattribute:: closed .. autoattribute:: is_client .. autoattribute:: is_server .. autoattribute:: local .. autoattribute:: remote This object exposes the following properties related to the WebSocket handshake. .. autoattribute:: path .. autoattribute:: subprotocol .. autoattribute:: handshake_headers A connection object has a pair of methods for sending and receiving WebSocket messages. Messages can be ``str`` or ``bytes`` objects. .. automethod:: send_message .. automethod:: get_message A connection object also has methods for sending pings and pongs. Each ping is sent with a unique payload, and the function blocks until a corresponding pong is received from the remote endpoint. This feature can be used to implement a bidirectional heartbeat. A pong, on the other hand, sends an unsolicited pong to the remote endpoint and does not expect or wait for a response. This feature can be used to implement a unidirectional heartbeat. .. automethod:: ping .. automethod:: pong Finally, the socket offers a method to close the connection. The connection context managers in :ref:`websocket-clients` and :ref:`websocket-servers` will automatically close the connection for you, but you may want to close the connection explicity if you are not using a context manager or if you want to customize the close reason. .. automethod:: aclose .. autoclass:: CloseReason :members: .. autoexception:: ConnectionClosed .. autoexception:: HandshakeError .. autoexception:: ConnectionRejected :show-inheritance: .. autoexception:: ConnectionTimeout :show-inheritance: .. autoexception:: DisconnectionTimeout :show-inheritance: Utilities --------- These are classes that you do not need to instantiate yourself, but you may get access to instances of these classes through other APIs. .. autoclass:: trio_websocket.Endpoint :members: python-trio-websocket-0.11.1/docs/backpressure.rst000066400000000000000000000041041450466364500222410ustar00rootroot00000000000000Message Queues ============== .. currentmodule:: trio_websocket .. TODO This file will grow into a "backpressure" document once #65 is complete. For now it is just deals with userspace buffers, since this is a related topic. When a connection is open, it runs a background task that reads network data and automatically handles certain types of events for you. For example, if the background task receives a ping event, then it will automatically send back a pong event. When the background task receives a message, it places that message into an internal queue. When you call ``get_message()``, it returns the first item from this queue. If this internal message queue does not have any size limits, then a remote endpoint could rapidly send large messages and use up all of the memory on the local machine! In almost all situations, the message queue needs to have size limits, both in terms of the number of items and the size per message. These limits create an upper bound for the amount of memory that can be used by a single WebSocket connection. For example, if the queue size is 10 and the maximum message size is 1 megabyte, then the connection will use at most 10 megabytes of memory. When the message queue is full, the background task pauses and waits for the user to remove a message, i.e. call ``get_message()``. When the background task is paused, it stops processing background events like replying to ping events. If a message is received that is larger than the maximum message size, then the connection is automatically closed with code 1009 and the message is discarded. The library APIs each take arguments to configure the mesage buffer: ``message_queue_size`` and ``max_message_size``. By default the queue size is one and the maximum message size is 1 MiB. If you set queue size to zero, then the background task will block every time it receives a message until somebody calls ``get_message()``. For an unbounded queue—which is strongly discouraged—set the queue size to ``math.inf``. Likewise, the maximum message size may also be disabled by setting it to ``math.inf``. python-trio-websocket-0.11.1/docs/clients.rst000066400000000000000000000041511450466364500212130ustar00rootroot00000000000000.. _websocket-clients: Clients ======= .. currentmodule:: trio_websocket Client Tutorial --------------- This page goes into the details of creating a WebSocket client. Let's start by revisiting the :ref:`client-example`. .. code-block:: python :linenos: import trio from trio_websocket import open_websocket_url async def main(): try: async with open_websocket_url('wss://localhost/foo') as ws: await ws.send_message('hello world!') message = await ws.get_message() logging.info('Received message: %s', message) except OSError as ose: logging.error('Connection attempt failed: %s', ose) trio.run(main) .. note:: A more complete example is included `in the repository `__. As explained in the tutorial, ``open_websocket_url(…)`` is a context manager that ensures the connection is properly opened and ready before entering the block. It also ensures that the connection is closed before exiting the block. This library contains two such context managers for creating client connections: one to connect by host and one to connect by URL. .. autofunction:: open_websocket :async-with: ws .. autofunction:: open_websocket_url :async-with: ws Custom Nursery -------------- The two context managers above create an internal nursery to run background tasks. If you wish to specify your own nursery instead, you should use the the following convenience functions instead. .. autofunction:: connect_websocket .. autofunction:: connect_websocket_url Custom Stream ------------- The WebSocket protocol is defined as an application layer protocol that runs on top of TCP, and the convenience functions described above automatically create those TCP connections. In more obscure use cases, you might want to run the WebSocket protocol on top of some other type of transport protocol. The library includes a convenience function that allows you to wrap any arbitrary Trio stream with a client WebSocket. .. autofunction:: wrap_client_stream python-trio-websocket-0.11.1/docs/conf.py000066400000000000000000000124621450466364500203230ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- project = 'Trio WebSocket' copyright = '2018, Hyperion Gray' author = 'Hyperion Gray' from trio_websocket._version import __version__ as version release = version # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinxcontrib_trio', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = 'TrioWebSocketdoc' # -- Options for LaTeX output ------------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'TrioWebSocket.tex', 'Trio WebSocket Documentation', 'Hyperion Gray', 'manual'), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'triowebsocket', 'Trio WebSocket Documentation', [author], 1) ] # -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'TrioWebSocket', 'Trio WebSocket Documentation', author, 'TrioWebSocket', 'One line description of project.', 'Miscellaneous'), ] # -- Options for Epub output ------------------------------------------------- # Bibliographic Dublin Core info. epub_title = project # The unique identifier of the text. This can be a ISBN number # or the project homepage. # # epub_identifier = '' # A unique identification for the text. # # epub_uid = '' # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] # -- Extension configuration ------------------------------------------------- intersphinx_mapping = { 'trio': ('https://trio.readthedocs.io/en/stable/', None), } python-trio-websocket-0.11.1/docs/contributing.rst000066400000000000000000000163711450466364500222700ustar00rootroot00000000000000Contributing ============ .. _developer-installation: Developer Installation ---------------------- If you want to help contribute to ``trio-websocket``, then you will need to install additional dependencies that are used for testing and documentation. The following sequence of commands will clone the repository, create a virtual environment, and install the developer dependencies:: $ git clone git@github.com:python-trio/trio-websocket.git $ cd trio-websocket $ python3 -m venv venv $ source venv/bin/activate (venv) $ pip install -r requirements-dev-full.txt (venv) $ pip install -e . This example uses Python's built-in ``venv`` package, but you can of course use other virtual environment tools such as ``virtualenvwrapper``. The ``requirements-dev.in`` and ``requirements-extras.in`` files contain extra dependencies only needed for development, such as PyTest, Sphinx, etc. The ``.in`` files and ``setup.py`` are used to generate the pinned manifests ``requirements-dev-full.txt`` and ``requirements-dev.txt``, so that dependencies used in development and CI builds do not change arbitrarily over time. Unit Tests ---------- .. note:: This project has unit tests that are configured to run on all pull requests to automatically check for regressions. Each pull request should include unit test coverage before it is merged. The unit tests are written with `the PyTest framework `__. You can quickly run all unit tests from the project's root with a simple command:: (venv) $ pytest === test session starts === platform linux -- Python 3.6.6, pytest-3.8.0, py-1.6.0, pluggy-0.7.1 rootdir: /home/johndoe/code/trio-websocket, inifile: pytest.ini plugins: trio-0.5.0, cov-2.6.0 collected 27 items tests/test_connection.py ........................... [100%] === 27 passed in 0.41 seconds === You can enable code coverage reporting by adding the ``-cov=trio_websocket`` option to PyTest or using the Makefile target ``make test``:: (venv) $ pytest --cov=trio_websocket === test session starts === platform linux -- Python 3.6.6, pytest-3.8.0, py-1.6.0, pluggy-0.7.1 rootdir: /home/johndoe/code/trio-websocket, inifile: pytest.ini plugins: trio-0.5.0, cov-2.6.0 collected 27 items tests/test_connection.py ........................... [100%] ---------- coverage: platform darwin, python 3.7.0-final-0 ----------- Name Stmts Miss Cover ------------------------------------------------ trio_websocket/__init__.py 369 33 91% trio_websocket/_version.py 1 0 100% ------------------------------------------------ TOTAL 370 33 91% === 27 passed in 0.57 seconds === Documentation ------------- This documentation is stored in the repository in the ``/docs/`` directory. It is written with `RestructuredText markup `__ and processed by `Sphinx `__. To build documentation, run this command from the project root:: $ make docs The finished documentation can be found in ``/docs/_build/``. This documentation is published automatically to `Read The Docs `__ for all pushes to master or to a tag. Autobahn Client Tests --------------------- The Autobahn Test Suite contains over 500 integration tests for WebSocket servers and clients. These test suites are contained in a `Docker `__ container. You will need to install Docker before you can run these integration tests. To test the client, you will need two terminal windows. In the first terminal, run the following commands:: $ cd autobahn $ docker run -it --rm \ -v "${PWD}/config:/config" \ -v "${PWD}/reports:/reports" \ -p 9001:9001 \ --name autobahn \ crossbario/autobahn-testsuite The first time you run this command, Docker will download some files, which may take a few minutes. When the test suite is ready, it will display:: Autobahn WebSocket 0.8.0/0.10.9 Fuzzing Server (Port 9001) Ok, will run 249 test cases for any clients connecting Now in the second terminal, run the Autobahn client:: $ cd autobahn $ python client.py ws://localhost:9001 INFO:client:Case count=249 INFO:client:Running test case 1 of 249 INFO:client:Running test case 2 of 249 INFO:client:Running test case 3 of 249 INFO:client:Running test case 4 of 249 INFO:client:Running test case 5 of 249 When the client finishes running, an HTML report is published to the ``autobahn/reports/clients`` directory. If any tests fail, you can debug individual tests by specifying the integer test case ID (not the dotted test case ID), e.g. to run test case #29:: $ python client.py ws://localhost:9001 29 Autobahn Server Tests --------------------- Read the section on Autobahn client tests before you read this section. Once again, you will need two terminal windows. In the first terminal, run:: $ cd autobahn $ python server.py In the second terminal, you will run the Docker image:: $ cd autobahn $ docker run -it --rm \ -v "${PWD}/config:/config" \ -v "${PWD}/reports:/reports" \ -p 9000:9000 \ --name autobahn \ crossbario/autobahn-testsuite \ wstest --mode fuzzingclient --spec /config/fuzzingclient.json If a test fails, ``server.py`` does not support the same ``debug_cases`` argument as ``client.py``, but you can modify `fuzzingclient.json` to specify a subset of cases to run, e.g. ``3.*`` to run all test cases in section 3. .. note:: For OS X or Windows, you'll need to edit `fuzzingclient.json` and change the host from ``172.17.0.1`` to ``host.docker.internal``. Versioning ---------- This project `uses semantic versioning `__ for official releases. When a new version is released, the version number on the ``master`` branch will be incremented to the next expected release and suffixed "dev". For example, if we release version 1.1.0, then the version number on ``master`` might be set to ``1.2.0-dev``, indicating that the next expected release is ``1.2.0`` and that release is still under development. Release Process --------------- To release a new version of this library, we follow this process: 1. In ``_version.py`` on ``master`` branch, remove the ``-dev`` suffix from the version number, e.g. change ``1.2.0-dev`` to ``1.2.0``. 2. Commit ``_version.py``. 3. Create a tag, e.g. ``git tag 1.2.0``. 4. Push the commit and the tag, e.g. ``git push && git push origin 1.2.0``. 5. Wait for `Github CI `__ to finish building and ensure that the build is successful. 6. Wait for `Read The Docs `__ to finish building and ensure that the build is successful. 7. Ensure that the working copy is in a clean state, e.g. ``git status`` shows no changes. 8. Build package and submit to PyPI: ``make publish`` 9. In ``_version.py`` on ``master`` branch, increment the version number to the next expected release and add the ``-dev`` suffix, e.g. change ``1.2.0`` to ``1.3.0-dev``. 10. Commit and push ``_version.py``. python-trio-websocket-0.11.1/docs/credits.rst000066400000000000000000000010441450466364500212050ustar00rootroot00000000000000Credits ======= Thanks to `John Belmonte (@belm0) `__ and `Nathaniel J. Smith (@njsmith) `__ for lots of feedback, discussion, code reviews, and pull requests. Thanks to all the Trio contributors for making a fantastic framework! Thanks to Hyperion Gray for supporting development time on this project. .. image:: https://hyperiongray.s3.amazonaws.com/define-hg.svg :target: https://www.hyperiongray.com/?pk_campaign=github&pk_kwd=agnostic :alt: define hyperiongray :width: 500px python-trio-websocket-0.11.1/docs/getting_started.rst000066400000000000000000000055111450466364500227420ustar00rootroot00000000000000Getting Started =============== .. currentmodule:: trio_websocket Installation ------------ This library supports Python ≥3.5. The easiest installation method is to use PyPI. :: $ pip3 install trio-websocket You can also install from source. Visit `the project's GitHub page `__, where you can clone the repository or download a Zip file. Change into the project directory and run the following command. :: $ pip3 install . If you want to contribute to development of the library, also see :ref:`developer-installation`. .. _client-example: Client Example -------------- This example briefly demonstrates how to create a WebSocket client. .. code-block:: python :linenos: import trio from trio_websocket import open_websocket_url async def main(): try: async with open_websocket_url('wss://localhost/foo') as ws: await ws.send_message('hello world!') message = await ws.get_message() logging.info('Received message: %s', message) except OSError as ose: logging.error('Connection attempt failed: %s', ose) trio.run(main) The function :func:`open_websocket_url` is a context manager that automatically connects and performs the WebSocket handshake before entering the block. This ensures that the connection is usable before ``ws.send_message(…)`` is called. The context manager yields a :class:`WebSocketConnection` instance that is used to send and receive messages. The context manager also closes the connection before exiting the block. For more details and examples, see :ref:`websocket-clients`. .. _server-example: Server Example --------------- This example briefly demonstrates how to create a WebSocket server. This server is an *echo server*, i.e. it responds to each incoming message by sending back an identical message. .. code-block:: python :linenos: import trio from trio_websocket import serve_websocket, ConnectionClosed async def echo_server(request): ws = await request.accept() while True: try: message = await ws.get_message() await ws.send_message(message) except ConnectionClosed: break async def main(): await serve_websocket(echo_server, '127.0.0.1', 8000, ssl_context=None) trio.run(main) The function :func:`serve_websocket` requires a function that can handle each incoming connection. This handler function receives a :class:`WebSocketRequest` object that the server can use to inspect the client's handshake. Next, the server accepts the request in order to complete the handshake and receive a :class:`WebSocketConnection` instance that can be used to send and receive messages. For more details and examples, see :ref:`websocket-servers`. python-trio-websocket-0.11.1/docs/index.rst000066400000000000000000000025431450466364500206640ustar00rootroot00000000000000Trio WebSocket ============== This library is a WebSocket implementation for `the Trio framework `__ that strives for safety, correctness, and ergonomics. It is based on `wsproto `__, which is a `Sans-IO `__ state machine that implements most aspects of the WebSocket protocol, including framing, codecs, and events. The respository is hosted `on GitHub `__. This library passes `the Autobahn Test Suite `__. .. image:: https://img.shields.io/pypi/v/trio-websocket.svg?style=flat-square :alt: PyPI :target: https://pypi.org/project/trio-websocket/ .. image:: https://img.shields.io/pypi/pyversions/trio-websocket.svg?style=flat-square :alt: Python Versions .. image:: https://img.shields.io/github/license/python-trio/trio-websocket.svg?style=flat-square :alt: MIT License .. image:: https://img.shields.io/github/actions/workflow/status/python-trio/trio-websocket/ci.yml :alt: Build Status :target: https://github.com/python-trio/trio-websocket/actions/workflows/ci.yml .. toctree:: :maxdepth: 2 :caption: Contents: getting_started clients servers backpressure timeouts api recipes contributing credits python-trio-websocket-0.11.1/docs/make.bat000066400000000000000000000014231450466364500204240ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd python-trio-websocket-0.11.1/docs/recipes.rst000066400000000000000000000043411450466364500212050ustar00rootroot00000000000000Recipes ======= .. currentmodule:: trio_websocket This page contains notes and sample code for common usage scenarios with this library. Heartbeat --------- If you wish to keep a connection open for long periods of time but do not need to send messages frequently, then a heartbeat holds the connection open and also detects when the connection drops unexpectedly. The following recipe demonstrates how to implement a connection heartbeat using WebSocket's ping/pong feature. .. code-block:: python :linenos: async def heartbeat(ws, timeout, interval): ''' Send periodic pings on WebSocket ``ws``. Wait up to ``timeout`` seconds to send a ping and receive a pong. Raises ``TooSlowError`` if the timeout is exceeded. If a pong is received, then wait ``interval`` seconds before sending the next ping. This function runs until cancelled. :param ws: A WebSocket to send heartbeat pings on. :param float timeout: Timeout in seconds. :param float interval: Interval between receiving pong and sending next ping, in seconds. :raises: ``ConnectionClosed`` if ``ws`` is closed. :raises: ``TooSlowError`` if the timeout expires. :returns: This function runs until cancelled. ''' while True: with trio.fail_after(timeout): await ws.ping() await trio.sleep(interval) async def main(): async with open_websocket_url('ws://my.example/') as ws: async with trio.open_nursery() as nursery: nursery.start_soon(heartbeat, ws, 5, 1) # Your application code goes here: pass trio.run(main) Note that the :meth:`~WebSocketConnection.ping` method waits until it receives a pong frame, so it ensures that the remote endpoint is still responsive. If the connection is dropped unexpectedly or takes too long to respond, then ``heartbeat()`` will raise an exception that will cancel the nursery. You may wish to implement additional logic to automatically reconnect. A heartbeat feature can be enabled in the `example client `__. with the ``--heartbeat`` flag. python-trio-websocket-0.11.1/docs/servers.rst000066400000000000000000000036111450466364500212430ustar00rootroot00000000000000.. _websocket-servers: Servers ======= .. currentmodule:: trio_websocket Server Tutorial --------------- This page goes into the details of creating a WebSocket server. Let's start by revisiting the :ref:`server-example`. .. code-block:: python :linenos: import trio from trio_websocket import serve_websocket, ConnectionClosed async def echo_server(request): ws = await request.accept() while True: try: message = await ws.get_message() await ws.send_message(message) except ConnectionClosed: break async def main(): await serve_websocket(echo_server, '127.0.0.1', 8000, ssl_context=None) trio.run(main) .. note:: A more complete example is included `in the repository `__. As explained in the tutorial, a WebSocket server needs a handler function and a host/port to bind to. The handler function receives a :class:`WebSocketRequest` object, and it calls the request's :func:`~WebSocketRequest.accept` method to finish the handshake and obtain a :class:`WebSocketConnection` object. When the handler function exits, the connection is automatically closed. If the handler function raises an exception, the server will silently close the connection and cancel the tasks belonging to it. .. autofunction:: serve_websocket Custom Stream ------------- The WebSocket protocol is defined as an application layer protocol that runs on top of TCP, and the convenience functions described above automatically create those TCP connections. In more obscure use cases, you might want to run the WebSocket protocol on top of some other type of transport protocol. The library includes a convenience function that allows you to wrap any arbitrary Trio stream with a server WebSocket. .. autofunction:: wrap_server_stream python-trio-websocket-0.11.1/docs/timeouts.rst000066400000000000000000000216301450466364500214240ustar00rootroot00000000000000Timeouts ======== .. currentmodule:: trio_websocket Networking code is inherently complex due to the unpredictable nature of network failures and the possibility of a remote peer that is coded incorrectly—or even maliciously! Therefore, your code needs to deal with unexpected circumstances. One common failure mode that you should guard against is a slow or unresponsive peer. This page describes the timeout behavior in ``trio-websocket`` and shows various examples for implementing timeouts in your own code. Before reading this, you might find it helpful to read `"Timeouts and cancellation for humans" `__, an article written by Trio's author that describes an overall philosophy regarding timeouts. The short version is that Trio discourages libraries from using internal timeouts. Instead, it encourages the caller to enforce timeouts, which makes timeout code easier to compose and reason about. On the other hand, this library is intended to be safe to use, and omitting timeouts could be a dangerous flaw. Therefore, this library takes a balanced approach to timeouts, where high-level APIs have internal timeouts, but you may disable them or use lower-level APIs if you want more control over the behavior. Message Timeouts ---------------- As a motivating example, let's write a client that sends one message and then expects to receive one message. To guard against a misbehaving server or network, we want to place a 15 second timeout on this combined send/receive operation. In other libraries, you might find that the APIs have ``timeout`` arguments, but that style of timeout is very tedious when composing multiple operations. In Trio, we have helpful abstractions like cancel scopes, allowing us to implement our example like this: .. code-block:: python async with open_websocket_url('ws://my.example/') as ws: with trio.fail_after(15): await ws.send_message('test') msg = await ws.get_message() print('Received message: {}'.format(msg)) The 15 second timeout covers the cumulative time to send one message and to wait for one response. It raises ``TooSlowError`` if the runtime exceeds 15 seconds. Connection Timeouts ------------------- The example in the previous section ignores one obvious problem: what if connecting to the server or closing the connection takes a long time? How do we apply a timeout to those operations? One option is to put the entire connection inside a cancel scope: .. code-block:: python with trio.fail_after(15): async with open_websocket_url('ws://my.example/') as ws: await ws.send_message('test') msg = await ws.get_message() print('Received message: {}'.format(msg)) The approach suffices if we want to compose all four operations into one timeout: connect, send message, get message, and disconnect. But this approach will not work if want to separate the timeouts for connecting/disconnecting from the timeouts for sending and receiving. Let's write a new client that sends messages periodically, waiting up to 15 seconds for a response to each message before sending the next message. .. code-block:: python async with open_websocket_url('ws://my.example/') as ws: for _ in range(10): await trio.sleep(30) with trio.fail_after(15): await ws.send_message('test') msg = await ws.get_message() print('Received message: {}'.format(msg)) In this scenario, the ``for`` loop will take at least 300 seconds to run, so we would like to specify timeouts that apply to connecting and disconnecting but do not apply to the contents of the context manager block. This is tricky because the connecting and disconnecting are handled automatically inside the context manager :func:`open_websocket_url`. Here's one possible approach: .. code-block:: python with trio.fail_after(10) as cancel_scope: async with open_websocket_url('ws://my.example'): cancel_scope.deadline = math.inf for _ in range(10): await trio.sleep(30) with trio.fail_after(15): await ws.send_message('test') msg = await ws.get_message() print('Received message: {}'.format(msg)) cancel_scope.deadline = trio.current_time() + 5 This example places a 10 second timeout on connecting and a separate 5 second timeout on disconnecting. This is accomplished by wrapping the entire operation in a cancel scope and then modifying the cancel scope's deadline when entering and exiting the context manager block. This approach works but it is a bit complicated, and we don't want our safety mechanisms to be complicated! Therefore, the high-level client APIs :func:`open_websocket` and :func:`open_websocket_url` contain internal timeouts that apply only to connecting and disconnecting. Let's rewrite the previous example to use the library's internal timeouts: .. code-block:: python async with open_websocket_url('ws://my.example/', connect_timeout=10, disconnect_timeout=5) as ws: for _ in range(10): await trio.sleep(30) with trio.fail_after(15): await ws.send_message('test') msg = await ws.get_message() print('Received message: {}'.format(msg)) Just like the previous example, this puts a 10 second timeout on connecting, a separate 5 second timeout on disconnecting. These internal timeouts violate the Trio philosophy of composable timeouts, but hopefully the examples in this section have convinced you that breaking the rules a bit is justified by the improved safety and ergonomics of this version. In fact, these timeouts have actually been present in all of our examples so far! We just didn't see them because those arguments have default values. If you really don't like the internal timeouts, you can disable them by passing ``math.inf``, or you can use the low-level APIs instead. Timeouts on Low-level APIs -------------------------- In the previous section, we saw how the library's high-level APIs have internal timeouts. The low-level APIs, like :func:`connect_websocket` and :func:`connect_websocket_url` do not have internal timeouts, nor are they context managers. These characteristics make the low-level APIs suitable for situations where you want very fine-grained control over timeout behavior. .. code-block:: python async with trio.open_nursery(): with trio.fail_after(10): connection = await connect_websocket_url(nursery, 'ws://my.example/') try: for _ in range(10): await trio.sleep(30) with trio.fail_after(15): await ws.send_message('test') msg = await ws.get_message() print('Received message: {}'.format(msg)) finally: with trio.fail_after(5): await connection.aclose() This example applies the same 10 second timeout for connecting and 5 second timeout for disconnecting as seen in the previous section, but it uses the lower-level APIs. This approach gives you more control but the low-level APIs also require more boilerplate, such as creating a nursery and using try/finally to ensure that the connection is always closed. Server Timeouts --------------- The server API also has internal timeouts. These timeouts are configured when the server is created, and they are enforced on each connection. .. code-block:: python async def handler(request): ws = await request.accept() msg = await ws.get_message() print('Received message: {}'.format(msg)) await serve_websocket(handler, 'localhost', 8080, ssl_context=None, connect_timeout=10, disconnect_timeout=5) The server timeouts work slightly differently from the client timeouts. The server's connect timeout measures the time between receiving a new TCP connection and calling the user's handler. The connect timeout includes waiting for the client's side of the handshake (which is represented by the ``request`` object), *but it does not include the server's side of the handshake.* The server handshake needs to be performed inside the user's handler, e.g. ``await request.accept()``. The disconnect timeout applies to the time between the handler exiting and the connection being closed. Each handler is spawned inside of a nursery, so there is no way for connect and disconnect timeouts to raise exceptions to your code. (If they did raise exceptions, they would cancel your nursery and crash your server!) Instead, connect timeouts cause the connection to be silently closed, and the handler is never called. For disconnect timeouts, your handler has already exited, so a timeout will cause the connection to be silently closed. As with the client APIs, you can disable the internal timeouts by passing ``math.inf`` or you can use low-level APIs like :func:`wrap_server_stream`. python-trio-websocket-0.11.1/examples/000077500000000000000000000000001450466364500177055ustar00rootroot00000000000000python-trio-websocket-0.11.1/examples/client.html000066400000000000000000000044441450466364500220570ustar00rootroot00000000000000 WebSocket Test

WebSocket Test

Output:

Commands:

send <MESSAGE>   -> send message
close [<REASON>] -> politely close connection with optional reason
        

python-trio-websocket-0.11.1/examples/client.py000066400000000000000000000110571450466364500215410ustar00rootroot00000000000000''' This interactive WebSocket client allows the user to send frames to a WebSocket server, including text message, ping, and close frames. To use SSL/TLS: install the `trustme` package from PyPI and run the `generate-cert.py` script in this directory. ''' import argparse import logging import pathlib import ssl import sys import urllib.parse import trio from trio_websocket import open_websocket_url, ConnectionClosed, HandshakeError logging.basicConfig(level=logging.DEBUG) here = pathlib.Path(__file__).parent def commands(): ''' Print the supported commands. ''' print('Commands: ') print('send -> send message') print('ping -> send ping with payload') print('close [] -> politely close connection with optional reason') print() def parse_args(): ''' Parse command line arguments. ''' parser = argparse.ArgumentParser(description='Example trio-websocket client') parser.add_argument('--heartbeat', action='store_true', help='Create a heartbeat task') parser.add_argument('url', help='WebSocket URL to connect to') return parser.parse_args() async def main(args): ''' Main entry point, returning False in the case of logged error. ''' if urllib.parse.urlsplit(args.url).scheme == 'wss': # Configure SSL context to handle our self-signed certificate. Most # clients won't need to do this. try: ssl_context = ssl.create_default_context() ssl_context.load_verify_locations(here / 'fake.ca.pem') except FileNotFoundError: logging.error('Did not find file "fake.ca.pem". You need to run' ' generate-cert.py') return False else: ssl_context = None try: logging.debug('Connecting to WebSocket…') async with open_websocket_url(args.url, ssl_context) as conn: await handle_connection(conn, args.heartbeat) except HandshakeError as e: logging.error('Connection attempt failed: %s', e) return False async def handle_connection(ws, use_heartbeat): ''' Handle the connection. ''' logging.debug('Connected!') try: async with trio.open_nursery() as nursery: if use_heartbeat: nursery.start_soon(heartbeat, ws, 1, 15) nursery.start_soon(get_commands, ws) nursery.start_soon(get_messages, ws) except ConnectionClosed as cc: reason = '' if cc.reason.reason is None else f'"{cc.reason.reason}"' print(f'Closed: {cc.reason.code}/{cc.reason.name} {reason}') async def heartbeat(ws, timeout, interval): ''' Send periodic pings on WebSocket ``ws``. Wait up to ``timeout`` seconds to send a ping and receive a pong. Raises ``TooSlowError`` if the timeout is exceeded. If a pong is received, then wait ``interval`` seconds before sending the next ping. This function runs until cancelled. :param ws: A WebSocket to send heartbeat pings on. :param float timeout: Timeout in seconds. :param float interval: Interval between receiving pong and sending next ping, in seconds. :raises: ``ConnectionClosed`` if ``ws`` is closed. :raises: ``TooSlowError`` if the timeout expires. :returns: This function runs until cancelled. ''' while True: with trio.fail_after(timeout): await ws.ping() await trio.sleep(interval) async def get_commands(ws): ''' In a loop: get a command from the user and execute it. ''' while True: cmd = await trio.to_thread.run_sync(input, 'cmd> ', cancellable=True) if cmd.startswith('ping'): payload = cmd[5:].encode('utf8') or None await ws.ping(payload) elif cmd.startswith('send'): message = cmd[5:] or None if message is None: logging.error('The "send" command requires a message.') else: await ws.send_message(message) elif cmd.startswith('close'): reason = cmd[6:] or None await ws.aclose(code=1000, reason=reason) break else: commands() # Allow time to receive response and log print logs: await trio.sleep(0.25) async def get_messages(ws): ''' In a loop: get a WebSocket message and print it out. ''' while True: message = await ws.get_message() print(f'message: {message}') if __name__ == '__main__': try: if not trio.run(main, parse_args()): sys.exit(1) except (KeyboardInterrupt, EOFError): print() python-trio-websocket-0.11.1/examples/generate-cert.py000066400000000000000000000013651450466364500230110ustar00rootroot00000000000000import pathlib import sys import trustme def main(): here = pathlib.Path(__file__).parent ca_path = here / 'fake.ca.pem' server_path = here / 'fake.server.pem' if ca_path.exists() and server_path.exists(): print('The CA ceritificate and server certificate already exist.') sys.exit(1) print('Creating self-signed certificate for localhost/127.0.0.1:') ca_cert = trustme.CA() ca_cert.cert_pem.write_to_path(ca_path) print(f' * CA certificate: {ca_path}') server_cert = ca_cert.issue_server_cert('localhost', '127.0.0.1') server_cert.private_key_and_cert_chain_pem.write_to_path(server_path) print(f' * Server certificate: {server_path}') print('Done') if __name__ == '__main__': main() python-trio-websocket-0.11.1/examples/server.py000066400000000000000000000041661450466364500215740ustar00rootroot00000000000000''' This simple WebSocket server responds to text messages by reversing each message string and sending it back. It also handles ping/pong automatically and will correctly close down a connection when the client requests it. To use SSL/TLS: install the `trustme` package from PyPI and run the `generate-cert.py` script in this directory. ''' import argparse import logging import pathlib import ssl import trio from trio_websocket import serve_websocket, ConnectionClosed logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger() here = pathlib.Path(__file__).parent def parse_args(): ''' Parse command line arguments. ''' parser = argparse.ArgumentParser(description='Example trio-websocket client') parser.add_argument('--ssl', action='store_true', help='Use SSL') parser.add_argument('host', help='Host interface to bind. If omitted, ' 'then bind all interfaces.', nargs='?') parser.add_argument('port', type=int, help='Port to bind.') return parser.parse_args() async def main(args): ''' Main entry point. ''' logging.info('Starting websocket server…') if args.ssl: ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) try: ssl_context.load_cert_chain(here / 'fake.server.pem') except FileNotFoundError: logging.error('Did not find file "fake.server.pem". You need to run' ' generate-cert.py') else: ssl_context = None host = None if args.host == '*' else args.host await serve_websocket(handler, host, args.port, ssl_context) async def handler(request): ''' Reverse incoming websocket messages and send them back. ''' logging.info('Handler starting on path "%s"', request.path) ws = await request.accept() while True: try: message = await ws.get_message() await ws.send_message(message[::-1]) except ConnectionClosed: logging.info('Connection closed') break logging.info('Handler exiting') if __name__ == '__main__': try: trio.run(main, parse_args()) except KeyboardInterrupt: print() python-trio-websocket-0.11.1/pylintrc000066400000000000000000000012731450466364500176610ustar00rootroot00000000000000[MASTER] disable=duplicate-code, fixme, invalid-name, missing-docstring, not-async-context-manager, protected-access, redefined-outer-name, too-few-public-methods, too-many-ancestors, too-many-arguments, too-many-boolean-expressions, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-nested-blocks, too-many-public-methods, too-many-return-statements, too-many-statements, unused-argument, unused-variable, wrong-spelling-in-comment, wrong-spelling-in-docstring [REPORTS] score=no python-trio-websocket-0.11.1/pytest.ini000066400000000000000000000000321450466364500201130ustar00rootroot00000000000000[pytest] trio_mode = true python-trio-websocket-0.11.1/requirements-dev-full.txt000066400000000000000000000101641450466364500230710ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.7 # by the following command: # # pip-compile --output-file=requirements-dev-full.txt requirements-dev.in requirements-extras.in setup.py # alabaster==0.7.13 # via sphinx astroid==2.15.0 # via pylint async-generator==1.10 # via trio attrs==22.2.0 # via # -r requirements-dev.in # outcome # pytest # trio babel==2.12.1 # via sphinx bleach==6.0.0 # via readme-renderer build==0.10.0 # via pip-tools certifi==2022.12.7 # via requests cffi==1.15.1 # via cryptography charset-normalizer==3.1.0 # via requests click==8.1.3 # via pip-tools coverage[toml]==7.2.1 # via pytest-cov cryptography==39.0.2 # via trustme dill==0.3.6 # via pylint docutils==0.18.1 # via # readme-renderer # sphinx # sphinx-rtd-theme exceptiongroup==1.1.0 # via # pytest # trio # trio-websocket (setup.py) h11==0.14.0 # via wsproto idna==3.4 # via # requests # trio # trustme imagesize==1.4.1 # via sphinx importlib-metadata==6.0.0 # via # build # click # keyring # pluggy # pytest # sphinx # twine importlib-resources==5.12.0 # via keyring iniconfig==2.0.0 # via pytest isort==5.11.5 # via pylint jaraco-classes==3.2.3 # via keyring jinja2==3.1.2 # via sphinx keyring==23.13.1 # via twine lazy-object-proxy==1.9.0 # via astroid markdown-it-py==2.2.0 # via rich markupsafe==2.1.2 # 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 # via # pytest-trio # trio packaging==23.0 # via # build # pytest # sphinx pip-tools==6.12.3 # via -r requirements-dev.in pkginfo==1.9.6 # via twine platformdirs==3.1.1 # via pylint pluggy==1.0.0 # via pytest pycparser==2.21 # via cffi pygments==2.14.0 # via # readme-renderer # rich # sphinx pylint==2.17.0 # via -r requirements-extras.in pyproject-hooks==1.0.0 # via build pytest==7.2.2 # via # -r requirements-dev.in # pytest-cov # pytest-trio pytest-cov==4.0.0 # via -r requirements-dev.in pytest-trio==0.8.0 # via -r requirements-dev.in pytz==2022.7.1 # via babel readme-renderer==37.3 # via twine requests==2.28.2 # via # requests-toolbelt # sphinx # twine requests-toolbelt==0.10.1 # via twine rfc3986==2.0.0 # via twine rich==13.3.2 # via twine six==1.16.0 # via bleach sniffio==1.3.0 # via trio snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via trio sphinx==5.3.0 # via # -r requirements-extras.in # sphinx-rtd-theme # sphinxcontrib-trio sphinx-rtd-theme==1.2.0 # via -r requirements-extras.in sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==2.0.0 # via sphinx sphinxcontrib-jquery==2.0.0 # via sphinx-rtd-theme sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx sphinxcontrib-trio==1.1.2 # via -r requirements-extras.in tomli==2.0.1 # via # build # coverage # pylint # pyproject-hooks # pytest tomlkit==0.11.6 # via pylint trio==0.22.0 # via # pytest-trio # trio-websocket (setup.py) trustme==0.9.0 # via -r requirements-dev.in twine==4.0.2 # via -r requirements-extras.in typed-ast==1.5.4 # via astroid typing-extensions==4.5.0 # via # astroid # h11 # importlib-metadata # markdown-it-py # platformdirs # pylint # rich urllib3==1.26.15 # via # requests # twine webencodings==0.5.1 # via bleach wheel==0.38.4 # via pip-tools wrapt==1.15.0 # via astroid wsproto==1.2.0 # via trio-websocket (setup.py) zipp==3.15.0 # via # importlib-metadata # importlib-resources # The following packages are considered to be unsafe in a requirements file: # pip # setuptools python-trio-websocket-0.11.1/requirements-dev.in000066400000000000000000000002121450466364500217110ustar00rootroot00000000000000# requirements for `make test` and dependency management attrs>=19.2.0 pip-tools>=5.5.0 pytest>=4.6 pytest-cov pytest-trio>=0.5.0 trustme python-trio-websocket-0.11.1/requirements-dev.txt000066400000000000000000000034641450466364500221360ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.7 # by the following command: # # pip-compile --output-file=requirements-dev.txt requirements-dev.in setup.py # async-generator==1.10 # via trio attrs==22.2.0 # via # -r requirements-dev.in # outcome # pytest # trio build==0.10.0 # via pip-tools cffi==1.15.1 # via cryptography click==8.1.3 # via pip-tools coverage[toml]==7.2.1 # via pytest-cov cryptography==41.0.4 # via trustme exceptiongroup==1.1.0 # via # pytest # trio # trio-websocket (setup.py) h11==0.14.0 # via wsproto idna==3.4 # via # trio # trustme importlib-metadata==6.0.0 # via # build # click # pluggy # pytest iniconfig==2.0.0 # via pytest outcome==1.2.0 # via # pytest-trio # trio packaging==23.0 # via # build # pytest pip-tools==6.12.3 # via -r requirements-dev.in pluggy==1.0.0 # via pytest pycparser==2.21 # via cffi pyproject-hooks==1.0.0 # via build pytest==7.2.2 # via # -r requirements-dev.in # pytest-cov # pytest-trio pytest-cov==4.0.0 # via -r requirements-dev.in pytest-trio==0.8.0 # via -r requirements-dev.in sniffio==1.3.0 # via trio sortedcontainers==2.4.0 # via trio tomli==2.0.1 # via # build # coverage # pyproject-hooks # pytest trio==0.22.0 # via # pytest-trio # trio-websocket (setup.py) trustme==0.9.0 # via -r requirements-dev.in typing-extensions==4.5.0 # via # h11 # importlib-metadata wheel==0.38.4 # via pip-tools wsproto==1.2.0 # via trio-websocket (setup.py) zipp==3.15.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip # setuptools python-trio-websocket-0.11.1/requirements-extras.in000066400000000000000000000001441450466364500224450ustar00rootroot00000000000000# requirements for `make lint/docs/publish` pylint sphinx sphinxcontrib-trio sphinx_rtd_theme twine python-trio-websocket-0.11.1/setup.py000066400000000000000000000033741450466364500176100ustar00rootroot00000000000000from setuptools import setup, find_packages from pathlib import Path here = Path(__file__).parent # Get version version = {} with (here / "trio_websocket" / "_version.py").open() as f: exec(f.read(), version) # Get description with (here / 'README.md').open(encoding='utf-8') as f: long_description = f.read() setup( name='trio-websocket', version=version['__version__'], description='WebSocket library for Trio', long_description=long_description, long_description_content_type='text/markdown', url='https://github.com/python-trio/trio-websocket', author='Mark E. Haase', author_email='mehaase@gmail.com', classifiers=[ # See https://pypi.org/classifiers/ 'Development Status :: 3 - Alpha', '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', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], python_requires=">=3.7", keywords='websocket client server trio', packages=find_packages(exclude=['docs', 'examples', 'tests']), install_requires=[ 'exceptiongroup; python_version<"3.11"', 'trio>=0.11', 'wsproto>=0.14', ], project_urls={ 'Bug Reports': 'https://github.com/python-trio/trio-websocket/issues', 'Source': 'https://github.com/python-trio/trio-websocket', }, ) python-trio-websocket-0.11.1/tests/000077500000000000000000000000001450466364500172315ustar00rootroot00000000000000python-trio-websocket-0.11.1/tests/__init__.py000066400000000000000000000000001450466364500213300ustar00rootroot00000000000000python-trio-websocket-0.11.1/tests/test_connection.py000066400000000000000000001151561450466364500230120ustar00rootroot00000000000000''' Unit tests for trio_websocket. Many of these tests involve networking, i.e. real TCP sockets. To maximize reliability, all networking tests should follow the following rules: - Use localhost only. This is stored in the ``HOST`` global variable. - Servers use dynamic ports: by passing zero as a port, the system selects a port that is guaranteed to be available. - The sequence of events between servers and clients should be controlled as much as possible to make tests as deterministic. More on determinism below. - If a test involves timing, e.g. a task needs to ``trio.sleep(…)`` for a bit, then the ``autojump_clock`` fixture should be used. - Most tests that involve I/O should have an absolute timeout placed on it to prevent a hung test from blocking the entire test suite. If a hung test is cancelled with ctrl+C, then PyTest discards its log messages, which makes debugging really difficult! The ``fail_after(…)`` decorator places an absolute timeout on test execution that as measured by Trio's clock. `Read more about writing tests with pytest-trio. `__ Determinism is an important property of tests, but it can be tricky to accomplish with network tests. For example, if a test has a client and a server, then they may race each other to close the connection first. The test author should select one side to always initiate the closing handshake. For example, if a test needs to ensure that the client closes first, then it can have the server call ``ws.get_message()`` without actually sending it a message. This will cause the server to block until the client has sent the closing handshake. In other circumstances ''' from functools import partial, wraps import ssl from unittest.mock import patch import attr import pytest import trio import trustme import wsproto from trio.testing import memory_stream_pair, memory_stream_pump from wsproto.events import CloseConnection 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_websocket import ( connect_websocket, connect_websocket_url, ConnectionClosed, ConnectionRejected, ConnectionTimeout, DisconnectionTimeout, Endpoint, HandshakeError, open_websocket, open_websocket_url, serve_websocket, WebSocketServer, WebSocketRequest, wrap_client_stream, wrap_server_stream ) WS_PROTO_VERSION = tuple(map(int, wsproto.__version__.split('.'))) HOST = '127.0.0.1' RESOURCE = '/resource' DEFAULT_TEST_MAX_DURATION = 1 # Timeout tests follow a general pattern: one side waits TIMEOUT seconds for an # event. The other side delays for FORCE_TIMEOUT seconds to force the timeout # to trigger. Each test also has maximum runtime (measure by Trio's clock) to # prevent a faulty test from hanging the entire suite. TIMEOUT = 1 FORCE_TIMEOUT = 2 TIMEOUT_TEST_MAX_DURATION = 3 @pytest.fixture async def echo_server(nursery): ''' A server that reads one message, sends back the same message, then closes the connection. ''' serve_fn = partial(serve_websocket, echo_request_handler, HOST, 0, ssl_context=None) server = await nursery.start(serve_fn) yield server @pytest.fixture async def echo_conn(echo_server): ''' Return a client connection instance that is connected to an echo server. ''' async with open_websocket(HOST, echo_server.port, RESOURCE, use_ssl=False) as conn: yield conn async def echo_request_handler(request): ''' Accept incoming request and then pass off to echo connection handler. ''' conn = await request.accept() try: msg = await conn.get_message() await conn.send_message(msg) except ConnectionClosed: pass class fail_after: ''' This decorator fails if the runtime of the decorated function (as measured by the Trio clock) exceeds the specified value. ''' def __init__(self, seconds): self._seconds = seconds def __call__(self, fn): @wraps(fn) async def wrapper(*args, **kwargs): with trio.move_on_after(self._seconds) as cancel_scope: await fn(*args, **kwargs) if cancel_scope.cancelled_caught: pytest.fail(f'Test runtime exceeded the maximum {self._seconds} seconds') return wrapper @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)) accept_hook = attr.ib(default=None) async def connect(self): assert not self.closed client, server = memory_stream_pair() await self.queued_streams[0].send(server) return client async def accept(self): await trio.sleep(0) assert not self.closed if self.accept_hook is not None: await self.accept_hook() stream = await self.queued_streams[1].receive() self.accepted_streams.append(stream) return stream async def aclose(self): self.closed = True await trio.sleep(0) async def test_endpoint_ipv4(): e1 = Endpoint('10.105.0.2', 80, False) assert e1.url == 'ws://10.105.0.2' assert str(e1) == 'Endpoint(address="10.105.0.2", port=80, is_ssl=False)' e2 = Endpoint('127.0.0.1', 8000, False) assert e2.url == 'ws://127.0.0.1:8000' assert str(e2) == 'Endpoint(address="127.0.0.1", port=8000, is_ssl=False)' e3 = Endpoint('0.0.0.0', 443, True) assert e3.url == 'wss://0.0.0.0' assert str(e3) == 'Endpoint(address="0.0.0.0", port=443, is_ssl=True)' async def test_listen_port_ipv6(): e1 = Endpoint('2599:8807:6201:b7:16cf:bb9c:a6d3:51ab', 80, False) assert e1.url == 'ws://[2599:8807:6201:b7:16cf:bb9c:a6d3:51ab]' assert str(e1) == 'Endpoint(address="2599:8807:6201:b7:16cf:bb9c:a6d3' \ ':51ab", port=80, is_ssl=False)' e2 = Endpoint('::1', 8000, False) assert e2.url == 'ws://[::1]:8000' assert str(e2) == 'Endpoint(address="::1", port=8000, is_ssl=False)' e3 = Endpoint('::', 443, True) assert e3.url == 'wss://[::]' assert str(e3) == 'Endpoint(address="::", port=443, is_ssl=True)' async def test_server_has_listeners(nursery): server = await nursery.start(serve_websocket, echo_request_handler, HOST, 0, None) assert len(server.listeners) > 0 assert isinstance(server.listeners[0], Endpoint) async def test_serve(nursery): task = current_task() server = await nursery.start(serve_websocket, echo_request_handler, HOST, 0, None) port = server.port assert server.port != 0 # The server nursery begins with one task (server.listen). assert len(nursery.child_tasks) == 1 no_clients_nursery_count = len(task.child_nurseries) async with open_websocket(HOST, port, RESOURCE, use_ssl=False) as conn: # The server nursery has the same number of tasks, but there is now # one additional nested nursery. assert len(nursery.child_tasks) == 1 assert len(task.child_nurseries) == no_clients_nursery_count + 1 async def test_serve_ssl(nursery): server_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) client_context = ssl.create_default_context() ca = trustme.CA() ca.configure_trust(client_context) cert = ca.issue_server_cert(HOST) cert.configure_cert(server_context) server = await nursery.start(serve_websocket, echo_request_handler, HOST, 0, server_context) port = server.port async with open_websocket(HOST, port, RESOURCE, use_ssl=client_context ) as conn: assert not conn.closed assert conn.local.is_ssl assert conn.remote.is_ssl async def test_serve_handler_nursery(nursery): task = current_task() async with trio.open_nursery() as handler_nursery: serve_with_nursery = partial(serve_websocket, echo_request_handler, HOST, 0, None, handler_nursery=handler_nursery) server = await nursery.start(serve_with_nursery) port = server.port # The server nursery begins with one task (server.listen). assert len(nursery.child_tasks) == 1 no_clients_nursery_count = len(task.child_nurseries) async with open_websocket(HOST, port, RESOURCE, use_ssl=False) as conn: # The handler nursery should have one task in it # (conn._reader_task). assert len(handler_nursery.child_tasks) == 1 async def test_serve_with_zero_listeners(nursery): task = current_task() with pytest.raises(ValueError): server = WebSocketServer(echo_request_handler, []) async def test_serve_non_tcp_listener(nursery): listeners = [MemoryListener()] server = WebSocketServer(echo_request_handler, listeners) await nursery.start(server.run) assert len(server.listeners) == 1 with pytest.raises(RuntimeError): server.port # pylint: disable=pointless-statement assert server.listeners[0].startswith('MemoryListener(') async def test_serve_multiple_listeners(nursery): listener1 = (await trio.open_tcp_listeners(0, host=HOST))[0] listener2 = MemoryListener() server = WebSocketServer(echo_request_handler, [listener1, listener2]) await nursery.start(server.run) assert len(server.listeners) == 2 with pytest.raises(RuntimeError): # Even though the first listener has a port, this property is only # usable if you have exactly one listener. server.port # pylint: disable=pointless-statement # The first listener metadata is a ListenPort instance. assert server.listeners[0].port != 0 # The second listener metadata is a string containing the repr() of a # MemoryListener object. assert server.listeners[1].startswith('MemoryListener(') async def test_client_open(echo_server): async with open_websocket(HOST, echo_server.port, RESOURCE, use_ssl=False) \ as conn: assert not conn.closed assert conn.is_client assert str(conn).startswith('client-') @pytest.mark.parametrize('path, expected_path', [ ('/', '/'), ('', '/'), (RESOURCE + '/path', RESOURCE + '/path'), (RESOURCE + '?foo=bar', RESOURCE + '?foo=bar') ]) async def test_client_open_url(path, expected_path, echo_server): url = f'ws://{HOST}:{echo_server.port}{path}' async with open_websocket_url(url) as conn: assert conn.path == expected_path async def test_client_open_invalid_url(echo_server): with pytest.raises(ValueError): async with open_websocket_url('http://foo.com/bar') as conn: pass async def test_ascii_encoded_path_is_ok(echo_server): path = '%D7%90%D7%91%D7%90?%D7%90%D7%9E%D7%90' url = f'ws://{HOST}:{echo_server.port}{RESOURCE}/{path}' async with open_websocket_url(url) as conn: assert conn.path == RESOURCE + '/' + path @patch('trio_websocket._impl.open_websocket') def test_client_open_url_options(open_websocket_mock): """open_websocket_url() must pass its options on to open_websocket()""" port = 1234 url = f'ws://{HOST}:{port}{RESOURCE}' options = { 'subprotocols': ['chat'], 'extra_headers': [(b'X-Test-Header', b'My test header')], 'message_queue_size': 9, 'max_message_size': 333, 'connect_timeout': 36, 'disconnect_timeout': 37, } open_websocket_url(url, **options) _, call_args, call_kwargs = open_websocket_mock.mock_calls[0] assert call_args == (HOST, port, RESOURCE) assert not call_kwargs.pop('use_ssl') assert call_kwargs == options open_websocket_url(url.replace('ws:', 'wss:')) _, call_args, call_kwargs = open_websocket_mock.mock_calls[1] assert call_kwargs['use_ssl'] async def test_client_connect(echo_server, nursery): conn = await connect_websocket(nursery, HOST, echo_server.port, RESOURCE, use_ssl=False) assert not conn.closed async def test_client_connect_url(echo_server, nursery): url = f'ws://{HOST}:{echo_server.port}{RESOURCE}' conn = await connect_websocket_url(nursery, url) assert not conn.closed async def test_connection_has_endpoints(echo_conn): async with echo_conn: assert isinstance(echo_conn.local, Endpoint) assert str(echo_conn.local.address) == HOST assert echo_conn.local.port > 1024 assert not echo_conn.local.is_ssl assert isinstance(echo_conn.remote, Endpoint) assert str(echo_conn.remote.address) == HOST assert echo_conn.remote.port > 1024 assert not echo_conn.remote.is_ssl @fail_after(1) async def test_handshake_has_endpoints(nursery): async def handler(request): assert str(request.local.address) == HOST assert request.local.port == server.port assert not request.local.is_ssl assert str(request.remote.address) == HOST assert not request.remote.is_ssl conn = await request.accept() server = await nursery.start(serve_websocket, handler, HOST, 0, None) async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False ) as client_ws: pass async def test_handshake_subprotocol(nursery): async def handler(request): assert request.proposed_subprotocols == ('chat', 'file') server_ws = await request.accept(subprotocol='chat') assert server_ws.subprotocol == 'chat' server = await nursery.start(serve_websocket, handler, HOST, 0, None) async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False, subprotocols=('chat', 'file')) as client_ws: assert client_ws.subprotocol == 'chat' async def test_handshake_path(nursery): async def handler(request): assert request.path == RESOURCE server_ws = await request.accept() assert server_ws.path == RESOURCE server = await nursery.start(serve_websocket, handler, HOST, 0, None) async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False, ) as client_ws: assert client_ws.path == RESOURCE @fail_after(1) async def test_handshake_client_headers(nursery): async def handler(request): headers = dict(request.headers) assert b'x-test-header' in headers assert headers[b'x-test-header'] == b'My test header' server_ws = await request.accept() await server_ws.send_message('test') server = await nursery.start(serve_websocket, handler, HOST, 0, None) headers = [(b'X-Test-Header', b'My test header')] async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False, extra_headers=headers) as client_ws: await client_ws.get_message() @fail_after(1) async def test_handshake_server_headers(nursery): async def handler(request): headers = [('X-Test-Header', 'My test header')] server_ws = await request.accept(extra_headers=headers) server = await nursery.start(serve_websocket, handler, HOST, 0, None) async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False ) as client_ws: header_key, header_value = client_ws.handshake_headers[0] assert header_key == b'x-test-header' assert header_value == b'My test header' @fail_after(1) async def test_handshake_exception_before_accept(): ''' 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. ''' async def handler(request): raise ValueError() with pytest.raises(ValueError): async with trio.open_nursery() as nursery: server = await nursery.start(serve_websocket, handler, HOST, 0, None) async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False) as client_ws: pass @fail_after(1) async def test_reject_handshake(nursery): async def handler(request): body = b'My body' await request.reject(400, body=body) server = await nursery.start(serve_websocket, handler, HOST, 0, None) with pytest.raises(ConnectionRejected) as exc_info: async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False, ) as client_ws: pass exc = exc_info.value assert exc.body == b'My body' @fail_after(1) async def test_reject_handshake_invalid_info_status(nursery): ''' An informational status code that is not 101 should cause the client to reject the handshake. Since it is an informational response, there will not be a response body, so this test exercises a different code path. ''' async def handler(stream): await stream.send_all(b'HTTP/1.1 100 CONTINUE\r\n\r\n') await stream.receive_some(max_bytes=1024) serve_fn = partial(trio.serve_tcp, handler, 0, host=HOST) listeners = await nursery.start(serve_fn) port = listeners[0].socket.getsockname()[1] with pytest.raises(ConnectionRejected) as exc_info: async with open_websocket(HOST, port, RESOURCE, use_ssl=False, ) as client_ws: pass exc = exc_info.value assert exc.status_code == 100 assert repr(exc) == 'ConnectionRejected' assert exc.body is None async def test_handshake_protocol_error(nursery, echo_server): ''' If a client connects to a trio-websocket server and tries to speak HTTP instead of WebSocket, the server should reject the connection. (If the server does not catch the protocol exception, it will raise an exception up to the nursery level and fail the test.) ''' client_stream = await trio.open_tcp_stream(HOST, echo_server.port) async with client_stream: await client_stream.send_all(b'GET / HTTP/1.1\r\n\r\n') response = await client_stream.receive_some(1024) assert response.startswith(b'HTTP/1.1 400') async def test_client_send_and_receive(echo_conn): async with echo_conn: await echo_conn.send_message('This is a test message.') received_msg = await echo_conn.get_message() assert received_msg == 'This is a test message.' async def test_client_send_invalid_type(echo_conn): async with echo_conn: with pytest.raises(ValueError): await echo_conn.send_message(object()) async def test_client_ping(echo_conn): async with echo_conn: await echo_conn.ping(b'A') with pytest.raises(ConnectionClosed): await echo_conn.ping(b'B') async def test_client_ping_two_payloads(echo_conn): pong_count = 0 async def ping_and_count(): nonlocal pong_count await echo_conn.ping() pong_count += 1 async with echo_conn: async with trio.open_nursery() as nursery: nursery.start_soon(ping_and_count) nursery.start_soon(ping_and_count) assert pong_count == 2 async def test_client_ping_same_payload(echo_conn): # This test verifies that two tasks can't ping with the same payload at the # same time. One of them should succeed and the other should get an # exception. exc_count = 0 async def ping_and_catch(): nonlocal exc_count try: await echo_conn.ping(b'A') except ValueError: exc_count += 1 async with echo_conn: async with trio.open_nursery() as nursery: nursery.start_soon(ping_and_catch) nursery.start_soon(ping_and_catch) assert exc_count == 1 async def test_client_pong(echo_conn): async with echo_conn: await echo_conn.pong(b'A') with pytest.raises(ConnectionClosed): await echo_conn.pong(b'B') async def test_client_default_close(echo_conn): async with echo_conn: assert not echo_conn.closed assert echo_conn.closed.code == 1000 assert echo_conn.closed.reason is None assert repr(echo_conn.closed) == 'CloseReason' async def test_client_nondefault_close(echo_conn): async with echo_conn: assert not echo_conn.closed await echo_conn.aclose(code=1001, reason='test reason') assert echo_conn.closed.code == 1001 assert echo_conn.closed.reason == 'test reason' async def test_wrap_client_stream(nursery): listener = MemoryListener() server = WebSocketServer(echo_request_handler, [listener]) await nursery.start(server.run) stream = await listener.connect() conn = await wrap_client_stream(nursery, stream, HOST, RESOURCE) async with conn: assert not conn.closed await conn.send_message('Hello from client!') msg = await conn.get_message() assert msg == 'Hello from client!' assert conn.local.startswith('StapledStream(') assert conn.closed async def test_wrap_server_stream(nursery): async def handler(stream): request = await wrap_server_stream(nursery, stream) server_ws = await request.accept() async with server_ws: assert not server_ws.closed msg = await server_ws.get_message() assert msg == 'Hello from client!' assert server_ws.closed serve_fn = partial(trio.serve_tcp, handler, 0, host=HOST) listeners = await nursery.start(serve_fn) port = listeners[0].socket.getsockname()[1] async with open_websocket(HOST, port, RESOURCE, use_ssl=False) as client: await client.send_message('Hello from client!') @fail_after(TIMEOUT_TEST_MAX_DURATION) async def test_client_open_timeout(nursery, autojump_clock): ''' The client times out waiting for the server to complete the opening handshake. ''' async def handler(request): await trio.sleep(FORCE_TIMEOUT) server_ws = await request.accept() pytest.fail('Should not reach this line.') server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None)) with pytest.raises(ConnectionTimeout): async with open_websocket(HOST, server.port, '/', use_ssl=False, connect_timeout=TIMEOUT) as client_ws: pass @fail_after(TIMEOUT_TEST_MAX_DURATION) async def test_client_close_timeout(nursery, autojump_clock): ''' This client times out waiting for the server to complete the closing handshake. To slow down the server's closing handshake, we make sure that its message queue size is 0, and the client sends it exactly 1 message. This blocks the server's reader so it won't do the closing handshake for at least ``FORCE_TIMEOUT`` seconds. ''' async def handler(request): server_ws = await request.accept() await trio.sleep(FORCE_TIMEOUT) # The next line should raise ConnectionClosed. await server_ws.get_message() pytest.fail('Should not reach this line.') server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None, message_queue_size=0)) with pytest.raises(DisconnectionTimeout): async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False, disconnect_timeout=TIMEOUT) as client_ws: await client_ws.send_message('test') async def test_client_connect_networking_error(): with patch('trio_websocket._impl.connect_websocket') as \ connect_websocket_mock: connect_websocket_mock.side_effect = OSError() with pytest.raises(HandshakeError): async with open_websocket(HOST, 0, '/', use_ssl=False) as client_ws: pass @fail_after(TIMEOUT_TEST_MAX_DURATION) async def test_server_open_timeout(autojump_clock): ''' The server times out waiting for the client to complete the opening handshake. Server timeouts don't raise exceptions, because handler tasks are launched in an internal nursery and sending exceptions wouldn't be helpful. Instead, timed out tasks silently end. ''' async def handler(request): pytest.fail('This handler should not be called.') async with trio.open_nursery() as nursery: server = await nursery.start(partial(serve_websocket, handler, HOST, 0, ssl_context=None, handler_nursery=nursery, connect_timeout=TIMEOUT)) old_task_count = len(nursery.child_tasks) # This stream is not a WebSocket, so it won't send a handshake: stream = await trio.open_tcp_stream(HOST, server.port) # Checkpoint so the server's handler task can spawn: await trio.sleep(0) assert len(nursery.child_tasks) == old_task_count + 1, \ "Server's reader task did not spawn" # Sleep long enough to trigger server's connect_timeout: await trio.sleep(FORCE_TIMEOUT) assert len(nursery.child_tasks) == old_task_count, \ "Server's reader task is still running" # Cancel the server task: nursery.cancel_scope.cancel() @fail_after(TIMEOUT_TEST_MAX_DURATION) async def test_server_close_timeout(autojump_clock): ''' The server times out waiting for the client to complete the closing handshake. Server timeouts don't raise exceptions, because handler tasks are launched in an internal nursery and sending exceptions wouldn't be helpful. Instead, timed out tasks silently end. To prevent the client from doing the closing handshake, we make sure that its message queue size is 0 and the server sends it exactly 1 message. This blocks the client's reader and prevents it from doing the client handshake. ''' async def handler(request): ws = await request.accept() # Send one message to block the client's reader task: await ws.send_message('test') async with trio.open_nursery() as outer: server = await outer.start(partial(serve_websocket, handler, HOST, 0, ssl_context=None, handler_nursery=outer, disconnect_timeout=TIMEOUT)) old_task_count = len(outer.child_tasks) # Spawn client inside an inner nursery so that we can cancel it's reader # so that it won't do a closing handshake. async with trio.open_nursery() as inner: ws = await connect_websocket(inner, HOST, server.port, RESOURCE, use_ssl=False) # Checkpoint so the server can spawn a handler task: await trio.sleep(0) assert len(outer.child_tasks) == old_task_count + 1, \ "Server's reader task did not spawn" # The client waits long enough to trigger the server's disconnect # timeout: await trio.sleep(FORCE_TIMEOUT) # The server should have cancelled the handler: assert len(outer.child_tasks) == old_task_count, \ "Server's reader task is still running" # Cancel the client's reader task: inner.cancel_scope.cancel() # Cancel the server task: outer.cancel_scope.cancel() async def test_client_does_not_close_handshake(nursery): async def handler(request): server_ws = await request.accept() with pytest.raises(ConnectionClosed): await server_ws.get_message() server = await nursery.start(serve_websocket, handler, HOST, 0, None) stream = await trio.open_tcp_stream(HOST, server.port) client_ws = await wrap_client_stream(nursery, stream, HOST, RESOURCE) async with client_ws: await stream.aclose() with pytest.raises(ConnectionClosed): await client_ws.send_message('Hello from client!') async def test_server_sends_after_close(nursery): done = trio.Event() async def handler(request): server_ws = await request.accept() with pytest.raises(ConnectionClosed): while True: await server_ws.send_message('Hello from server') done.set() server = await nursery.start(serve_websocket, handler, HOST, 0, None) stream = await trio.open_tcp_stream(HOST, server.port) client_ws = await wrap_client_stream(nursery, stream, HOST, RESOURCE) async with client_ws: # pump a few messages for x in range(2): await client_ws.send_message('Hello from client') await stream.aclose() await done.wait() async def test_server_does_not_close_handshake(nursery): async def handler(stream): request = await wrap_server_stream(nursery, stream) server_ws = await request.accept() async with server_ws: await stream.aclose() with pytest.raises(ConnectionClosed): await server_ws.send_message('Hello from client!') serve_fn = partial(trio.serve_tcp, handler, 0, host=HOST) listeners = await nursery.start(serve_fn) port = listeners[0].socket.getsockname()[1] async with open_websocket(HOST, port, RESOURCE, use_ssl=False) as client: with pytest.raises(ConnectionClosed): await client.get_message() async def test_server_handler_exit(nursery, autojump_clock): async def handler(request): server_ws = await request.accept() await trio.sleep(1) server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None)) # connection should close when server handler exits with trio.fail_after(2): async with open_websocket( HOST, server.port, '/', use_ssl=False) as connection: with pytest.raises(ConnectionClosed) as exc_info: await connection.get_message() exc = exc_info.value assert exc.reason.name == 'NORMAL_CLOSURE' @fail_after(DEFAULT_TEST_MAX_DURATION) async def test_read_messages_after_remote_close(nursery): ''' When the remote endpoint closes, the local endpoint can still read all of the messages sent prior to closing. Any attempt to read beyond that will raise ConnectionClosed. This test also exercises the configuration of the queue size. ''' server_closed = trio.Event() async def handler(request): server = await request.accept() async with server: await server.send_message('1') await server.send_message('2') server_closed.set() server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None)) # The client needs a message queue of size 2 so that it can buffer both # incoming messages without blocking the reader task. async with open_websocket(HOST, server.port, '/', use_ssl=False, message_queue_size=2) as client: await server_closed.wait() assert await client.get_message() == '1' assert await client.get_message() == '2' with pytest.raises(ConnectionClosed): await client.get_message() async def test_no_messages_after_local_close(nursery): ''' If the local endpoint initiates closing, then pending messages are discarded and any attempt to read a message will raise ConnectionClosed. ''' client_closed = trio.Event() async def handler(request): # The server sends some messages and then closes. server = await request.accept() async with server: await server.send_message('1') await server.send_message('2') await client_closed.wait() server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None)) async with open_websocket(HOST, server.port, '/', use_ssl=False) as client: pass with pytest.raises(ConnectionClosed): await client.get_message() client_closed.set() async def test_cm_exit_with_pending_messages(echo_server, autojump_clock): ''' Regression test for #74, where a context manager was not able to exit when there were pending messages in the receive queue. ''' with trio.fail_after(1): async with open_websocket(HOST, echo_server.port, RESOURCE, use_ssl=False) as ws: await ws.send_message('hello') # allow time for the server to respond await trio.sleep(.1) @fail_after(DEFAULT_TEST_MAX_DURATION) async def test_max_message_size(nursery): ''' Set the client's max message size to 100 bytes. The client can send a message larger than 100 bytes, but when it receives a message larger than 100 bytes, it closes the connection with code 1009. ''' async def handler(request): ''' Similar to the echo_request_handler fixture except it runs in a loop. ''' conn = await request.accept() while True: try: msg = await conn.get_message() await conn.send_message(msg) except ConnectionClosed: break server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None)) async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False, max_message_size=100) as client: # We can send and receive 100 bytes: await client.send_message(b'A' * 100) msg = await client.get_message() assert len(msg) == 100 # We can send 101 bytes but cannot receive 101 bytes: await client.send_message(b'B' * 101) with pytest.raises(ConnectionClosed): await client.get_message() assert client.closed assert client.closed.code == 1009 async def test_server_close_client_disconnect_race(nursery, autojump_clock): """server attempts close just as client disconnects (issue #96)""" async def handler(request: WebSocketRequest): ws = await request.accept() ws._for_testing_peer_closed_connection = trio.Event() await ws.send_message('foo') await ws._for_testing_peer_closed_connection.wait() # with bug, this would raise ConnectionClosed from websocket internal task await trio.aclose_forcefully(ws._stream) server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None)) connection = await connect_websocket(nursery, HOST, server.port, RESOURCE, use_ssl=False) await connection.get_message() await connection.aclose() await trio.sleep(.1) async def test_remote_close_local_message_race(nursery, autojump_clock): """as remote initiates close, local attempts message (issue #175) This exposed multiple problems in the trio-websocket API and implementation: * send_message() silently fails if a close is in progress. This was likely an oversight in the API, since send_message() raises `ConnectionClosed` only in the already-closed case, yet `ConnectionClosed` is defined to cover "in the process of closing". * with wsproto >= 1.2.0, LocalProtocolError will be leaked """ async def handler(request: WebSocketRequest): ws = await request.accept() await ws.get_message() await ws.aclose() server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None)) client = await connect_websocket(nursery, HOST, server.port, RESOURCE, use_ssl=False) client._for_testing_peer_closed_connection = trio.Event() await client.send_message('foo') await client._for_testing_peer_closed_connection.wait() with pytest.raises(ConnectionClosed): await client.send_message('bar') async def test_message_after_local_close_race(nursery): """test message send during local-initiated close handshake (issue #158)""" async def handler(request: WebSocketRequest): await request.accept() await trio.sleep_forever() server = await nursery.start( partial(serve_websocket, handler, HOST, 0, ssl_context=None)) client = await connect_websocket(nursery, HOST, server.port, RESOURCE, use_ssl=False) orig_send = client._send close_sent = trio.Event() async def _send_wrapper(event): if isinstance(event, CloseConnection): close_sent.set() return await orig_send(event) client._send = _send_wrapper assert not client.closed nursery.start_soon(client.aclose) await close_sent.wait() assert client.closed with pytest.raises(ConnectionClosed): await client.send_message('hello') @fail_after(DEFAULT_TEST_MAX_DURATION) async def test_server_tcp_closed_on_close_connection_event(nursery): """ensure server closes TCP immediately after receiving CloseConnection""" server_stream_closed = trio.Event() async def _close_stream_stub(): assert not server_stream_closed.is_set() server_stream_closed.set() async def handle_connection(request): ws = await request.accept() ws._close_stream = _close_stream_stub await trio.sleep_forever() server = await nursery.start( partial(serve_websocket, handle_connection, HOST, 0, ssl_context=None)) client = await connect_websocket(nursery, HOST, server.port, RESOURCE, use_ssl=False) # send a CloseConnection event to server but leave client connected await client._send(CloseConnection(code=1000)) await server_stream_closed.wait() async def test_finalization_dropped_exception(echo_server, autojump_clock): # Confirm that open_websocket finalization does not contribute to dropped # exceptions as described in https://github.com/python-trio/trio/issues/1559. with pytest.raises(ValueError): with trio.move_on_after(1): async with open_websocket(HOST, echo_server.port, RESOURCE, use_ssl=False): try: await trio.sleep_forever() finally: raise ValueError async def test_remote_close_rude(): """ Bad ordering: 1. Remote close 2. TCP closed 3. Local confirms => no ConnectionClosed raised, client hangs forever """ client_stream, server_stream = memory_stream_pair() async def client(): client_conn = await wrap_client_stream(nursery, client_stream, HOST, RESOURCE) assert not client_conn.closed await client_conn.send_message('Hello from client!') with pytest.raises(ConnectionClosed): await client_conn.get_message() async def server(): server_request = await wrap_server_stream(nursery, server_stream) server_ws = await server_request.accept() assert not server_ws.closed msg = await server_ws.get_message() assert msg == "Hello from client!" # disable pumping so that the CloseConnection arrives at the same time as the stream closure server_stream.send_stream.send_all_hook = None await server_ws._send(CloseConnection(code=1000, reason=None)) await server_stream.aclose() # pump the messages over memory_stream_pump(server_stream.send_stream, client_stream.receive_stream) async with trio.open_nursery() as nursery: nursery.start_soon(server) nursery.start_soon(client) python-trio-websocket-0.11.1/trio_websocket/000077500000000000000000000000001450466364500211125ustar00rootroot00000000000000python-trio-websocket-0.11.1/trio_websocket/__init__.py000066400000000000000000000006621450466364500232270ustar00rootroot00000000000000from ._impl import ( CloseReason, ConnectionClosed, ConnectionRejected, ConnectionTimeout, connect_websocket, connect_websocket_url, DisconnectionTimeout, Endpoint, HandshakeError, open_websocket, open_websocket_url, WebSocketConnection, WebSocketRequest, WebSocketServer, wrap_client_stream, wrap_server_stream, serve_websocket, ) from ._version import __version__ python-trio-websocket-0.11.1/trio_websocket/_impl.py000066400000000000000000001574501450466364500226000ustar00rootroot00000000000000import sys from collections import OrderedDict from contextlib import asynccontextmanager from functools import partial from ipaddress import ip_address import itertools import logging import random import ssl import struct import urllib.parse from typing import List, Optional, Union import trio import trio.abc from wsproto import ConnectionType, WSConnection from wsproto.connection import ConnectionState import wsproto.frame_protocol as wsframeproto from wsproto.events import ( AcceptConnection, BytesMessage, CloseConnection, Ping, Pong, RejectConnection, RejectData, Request, TextMessage, ) import wsproto.utilities if sys.version_info < (3, 11): # pragma: no cover # pylint doesn't care about the version_info check, so need to ignore the warning from exceptiongroup import BaseExceptionGroup # pylint: disable=redefined-builtin _TRIO_MULTI_ERROR = tuple(map(int, trio.__version__.split('.')[:2])) < (0, 22) CONN_TIMEOUT = 60 # default connect & disconnect timeout, in seconds MESSAGE_QUEUE_SIZE = 1 MAX_MESSAGE_SIZE = 2 ** 20 # 1 MiB RECEIVE_BYTES = 4 * 2 ** 10 # 4 KiB logger = logging.getLogger('trio-websocket') def _ignore_cancel(exc): return None if isinstance(exc, trio.Cancelled) else exc class _preserve_current_exception: """A context manager which should surround an ``__exit__`` or ``__aexit__`` handler or the contents of a ``finally:`` block. It ensures that any exception that was being handled upon entry is not masked by a `trio.Cancelled` raised within the body of the context manager. https://github.com/python-trio/trio/issues/1559 https://gitter.im/python-trio/general?at=5faf2293d37a1a13d6a582cf """ __slots__ = ("_armed",) def __init__(self): self._armed = False def __enter__(self): self._armed = sys.exc_info()[1] is not None def __exit__(self, ty, value, tb): if value is None or not self._armed: return False if _TRIO_MULTI_ERROR: # pragma: no cover filtered_exception = trio.MultiError.filter(_ignore_cancel, value) # pylint: disable=no-member elif isinstance(value, BaseExceptionGroup): filtered_exception = value.subgroup(lambda exc: not isinstance(exc, trio.Cancelled)) else: filtered_exception = _ignore_cancel(value) return filtered_exception is None @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): ''' Open a WebSocket client connection to a host. This async context manager connects when entering the context manager and disconnects when exiting. It yields a :class:`WebSocketConnection` instance. :param str host: The host to connect to. :param int port: The port to connect to. :param str resource: The resource, i.e. URL path. :param Union[bool, ssl.SSLContext] use_ssl: If this is an SSL context, then use that context. If this is ``True`` then use default SSL context. If this is ``False`` then disable SSL. :param subprotocols: An iterable of strings representing preferred subprotocols. :param list[tuple[bytes,bytes]] extra_headers: A list of 2-tuples containing HTTP header key/value pairs to send with the connection request. Note that headers used by the WebSocket protocol (e.g. ``Sec-WebSocket-Accept``) will be overwritten. :param int message_queue_size: The maximum number of messages that will be buffered in the library's internal message queue. :param int max_message_size: The maximum message size as measured by ``len()``. If a message is received that is larger than this size, then the connection is closed with code 1009 (Message Too Big). :param float connect_timeout: The number of seconds to wait for the connection before timing out. :param float disconnect_timeout: The number of seconds to wait when closing the connection before timing out. :raises HandshakeError: for any networking error, client-side timeout (:exc:`ConnectionTimeout`, :exc:`DisconnectionTimeout`), or server rejection (:exc:`ConnectionRejected`) during handshakes. ''' async with trio.open_nursery() as new_nursery: try: with trio.fail_after(connect_timeout): connection = await connect_websocket(new_nursery, host, port, resource, use_ssl=use_ssl, subprotocols=subprotocols, extra_headers=extra_headers, message_queue_size=message_queue_size, max_message_size=max_message_size) except trio.TooSlowError: raise ConnectionTimeout from None except OSError as e: raise HandshakeError from e try: yield connection finally: try: with trio.fail_after(disconnect_timeout): await connection.aclose() except trio.TooSlowError: raise DisconnectionTimeout from 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): ''' Return an open WebSocket client connection to a host. This function is used to specify a custom nursery to run connection background tasks in. The caller is responsible for closing the connection. If you don't need a custom nursery, you should probably use :func:`open_websocket` instead. :param nursery: A Trio nursery to run background tasks in. :param str host: The host to connect to. :param int port: The port to connect to. :param str resource: The resource, i.e. URL path. :param Union[bool, ssl.SSLContext] use_ssl: If this is an SSL context, then use that context. If this is ``True`` then use default SSL context. If this is ``False`` then disable SSL. :param subprotocols: An iterable of strings representing preferred subprotocols. :param list[tuple[bytes,bytes]] extra_headers: A list of 2-tuples containing HTTP header key/value pairs to send with the connection request. Note that headers used by the WebSocket protocol (e.g. ``Sec-WebSocket-Accept``) will be overwritten. :param int message_queue_size: The maximum number of messages that will be buffered in the library's internal message queue. :param int max_message_size: The maximum message size as measured by ``len()``. If a message is received that is larger than this size, then the connection is closed with code 1009 (Message Too Big). :rtype: WebSocketConnection ''' if use_ssl is True: ssl_context = ssl.create_default_context() elif use_ssl is False: ssl_context = None elif isinstance(use_ssl, ssl.SSLContext): ssl_context = use_ssl else: raise TypeError('`use_ssl` argument must be bool or ssl.SSLContext') logger.debug('Connecting to ws%s://%s:%d%s', '' if ssl_context is None else 's', host, port, resource) if ssl_context is None: stream = await trio.open_tcp_stream(host, port) else: stream = await trio.open_ssl_over_tcp_stream(host, port, ssl_context=ssl_context, https_compatible=True) if port in (80, 443): host_header = host else: host_header = f'{host}:{port}' connection = WebSocketConnection(stream, WSConnection(ConnectionType.CLIENT), host=host_header, path=resource, client_subprotocols=subprotocols, client_extra_headers=extra_headers, message_queue_size=message_queue_size, max_message_size=max_message_size) nursery.start_soon(connection._reader_task) await connection._open_handshake.wait() return connection def open_websocket_url(url, ssl_context=None, *, 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): ''' Open a WebSocket client connection to a URL. This async context manager connects when entering the context manager and disconnects when exiting. It yields a :class:`WebSocketConnection` instance. :param str url: A WebSocket URL, i.e. `ws:` or `wss:` URL scheme. :param ssl_context: Optional SSL context used for ``wss:`` URLs. A default SSL context is used for ``wss:`` if this argument is ``None``. :type ssl_context: ssl.SSLContext or None :param subprotocols: An iterable of strings representing preferred subprotocols. :param list[tuple[bytes,bytes]] extra_headers: A list of 2-tuples containing HTTP header key/value pairs to send with the connection request. Note that headers used by the WebSocket protocol (e.g. ``Sec-WebSocket-Accept``) will be overwritten. :param int message_queue_size: The maximum number of messages that will be buffered in the library's internal message queue. :param int max_message_size: The maximum message size as measured by ``len()``. If a message is received that is larger than this size, then the connection is closed with code 1009 (Message Too Big). :param float connect_timeout: The number of seconds to wait for the connection before timing out. :param float disconnect_timeout: The number of seconds to wait when closing the connection before timing out. :raises HandshakeError: for any networking error, client-side timeout (:exc:`ConnectionTimeout`, :exc:`DisconnectionTimeout`), or server rejection (:exc:`ConnectionRejected`) during handshakes. ''' host, port, resource, ssl_context = _url_to_host(url, ssl_context) return open_websocket(host, port, resource, use_ssl=ssl_context, subprotocols=subprotocols, extra_headers=extra_headers, message_queue_size=message_queue_size, max_message_size=max_message_size, connect_timeout=connect_timeout, disconnect_timeout=disconnect_timeout) async def connect_websocket_url(nursery, url, ssl_context=None, *, subprotocols=None, extra_headers=None, message_queue_size=MESSAGE_QUEUE_SIZE, max_message_size=MAX_MESSAGE_SIZE): ''' Return an open WebSocket client connection to a URL. This function is used to specify a custom nursery to run connection background tasks in. The caller is responsible for closing the connection. If you don't need a custom nursery, you should probably use :func:`open_websocket_url` instead. :param nursery: A nursery to run background tasks in. :param str url: A WebSocket URL. :param ssl_context: Optional SSL context used for ``wss:`` URLs. :type ssl_context: ssl.SSLContext or None :param subprotocols: An iterable of strings representing preferred subprotocols. :param list[tuple[bytes,bytes]] extra_headers: A list of 2-tuples containing HTTP header key/value pairs to send with the connection request. Note that headers used by the WebSocket protocol (e.g. ``Sec-WebSocket-Accept``) will be overwritten. :param int message_queue_size: The maximum number of messages that will be buffered in the library's internal message queue. :param int max_message_size: The maximum message size as measured by ``len()``. If a message is received that is larger than this size, then the connection is closed with code 1009 (Message Too Big). :rtype: WebSocketConnection ''' host, port, resource, ssl_context = _url_to_host(url, ssl_context) return await connect_websocket(nursery, host, port, resource, use_ssl=ssl_context, subprotocols=subprotocols, extra_headers=extra_headers, message_queue_size=message_queue_size, max_message_size=max_message_size) def _url_to_host(url, ssl_context): ''' Convert a WebSocket URL to a (host,port,resource) tuple. The returned ``ssl_context`` is either the same object that was passed in, or if ``ssl_context`` is None, then a bool indicating if a default SSL context needs to be created. :param str url: A WebSocket URL. :type ssl_context: ssl.SSLContext or None :returns: A tuple of ``(host, port, resource, ssl_context)``. ''' url = str(url) # For backward compat with isinstance(url, yarl.URL). parts = urllib.parse.urlsplit(url) if parts.scheme not in ('ws', 'wss'): raise ValueError('WebSocket URL scheme must be "ws:" or "wss:"') if ssl_context is None: ssl_context = parts.scheme == 'wss' elif parts.scheme == 'ws': raise ValueError('SSL context must be None for ws: URL scheme') host = parts.hostname if parts.port is not None: port = parts.port else: port = 443 if ssl_context else 80 path_qs = parts.path # RFC 7230, Section 5.3.1: # If the target URI's path component is empty, the client MUST # send "/" as the path within the origin-form of request-target. if not path_qs: path_qs = '/' if '?' in url: path_qs += '?' + parts.query return host, port, path_qs, ssl_context async def wrap_client_stream(nursery, stream, host, resource, *, subprotocols=None, extra_headers=None, message_queue_size=MESSAGE_QUEUE_SIZE, max_message_size=MAX_MESSAGE_SIZE): ''' Wrap an arbitrary stream in a WebSocket connection. This is a low-level function only needed in rare cases. In most cases, you should use :func:`open_websocket` or :func:`open_websocket_url`. :param nursery: A Trio nursery to run background tasks in. :param stream: A Trio stream to be wrapped. :type stream: trio.abc.Stream :param str host: A host string that will be sent in the ``Host:`` header. :param str resource: A resource string, i.e. the path component to be accessed on the server. :param subprotocols: An iterable of strings representing preferred subprotocols. :param list[tuple[bytes,bytes]] extra_headers: A list of 2-tuples containing HTTP header key/value pairs to send with the connection request. Note that headers used by the WebSocket protocol (e.g. ``Sec-WebSocket-Accept``) will be overwritten. :param int message_queue_size: The maximum number of messages that will be buffered in the library's internal message queue. :param int max_message_size: The maximum message size as measured by ``len()``. If a message is received that is larger than this size, then the connection is closed with code 1009 (Message Too Big). :rtype: WebSocketConnection ''' connection = WebSocketConnection(stream, WSConnection(ConnectionType.CLIENT), host=host, path=resource, client_subprotocols=subprotocols, client_extra_headers=extra_headers, message_queue_size=message_queue_size, max_message_size=max_message_size) nursery.start_soon(connection._reader_task) await connection._open_handshake.wait() return connection async def wrap_server_stream(nursery, stream, message_queue_size=MESSAGE_QUEUE_SIZE, max_message_size=MAX_MESSAGE_SIZE): ''' Wrap an arbitrary stream in a server-side WebSocket. This is a low-level function only needed in rare cases. In most cases, you should use :func:`serve_websocket`. :param nursery: A nursery to run background tasks in. :param stream: A stream to be wrapped. :param int message_queue_size: The maximum number of messages that will be buffered in the library's internal message queue. :param int max_message_size: The maximum message size as measured by ``len()``. If a message is received that is larger than this size, then the connection is closed with code 1009 (Message Too Big). :type stream: trio.abc.Stream :rtype: WebSocketRequest ''' connection = WebSocketConnection(stream, WSConnection(ConnectionType.SERVER), message_queue_size=message_queue_size, max_message_size=max_message_size) nursery.start_soon(connection._reader_task) request = await connection._get_request() return request async def serve_websocket(handler, host, port, ssl_context, *, handler_nursery=None, message_queue_size=MESSAGE_QUEUE_SIZE, max_message_size=MAX_MESSAGE_SIZE, connect_timeout=CONN_TIMEOUT, disconnect_timeout=CONN_TIMEOUT, task_status=trio.TASK_STATUS_IGNORED): ''' Serve a WebSocket over TCP. This function supports the Trio nursery start protocol: ``server = await nursery.start(serve_websocket, …)``. It will block until the server is accepting connections and then return a :class:`WebSocketServer` object. Note that if ``host`` is ``None`` and ``port`` is zero, then you may get multiple listeners that have *different port numbers!* :param handler: An async function that is invoked with a request for each new connection. :param host: The host interface to bind. This can be an address of an interface, a name that resolves to an interface address (e.g. ``localhost``), or a wildcard address like ``0.0.0.0`` for IPv4 or ``::`` for IPv6. If ``None``, then all local interfaces are bound. :type host: str, bytes, or None :param int port: The port to bind to. :param ssl_context: The SSL context to use for encrypted connections, or ``None`` for unencrypted connection. :type ssl_context: ssl.SSLContext or None :param handler_nursery: An optional nursery to spawn handlers and background tasks in. If not specified, a new nursery will be created internally. :param int message_queue_size: The maximum number of messages that will be buffered in the library's internal message queue. :param int max_message_size: The maximum message size as measured by ``len()``. If a message is received that is larger than this size, then the connection is closed with code 1009 (Message Too Big). :param float connect_timeout: The number of seconds to wait for a client to finish connection handshake before timing out. :param float disconnect_timeout: The number of seconds to wait for a client to finish the closing handshake before timing out. :param task_status: Part of Trio nursery start protocol. :returns: This function runs until cancelled. ''' if ssl_context is None: open_tcp_listeners = partial(trio.open_tcp_listeners, port, host=host) else: open_tcp_listeners = partial(trio.open_ssl_over_tcp_listeners, port, ssl_context, host=host, https_compatible=True) listeners = await open_tcp_listeners() server = WebSocketServer(handler, listeners, handler_nursery=handler_nursery, message_queue_size=message_queue_size, max_message_size=max_message_size, connect_timeout=connect_timeout, disconnect_timeout=disconnect_timeout) await server.run(task_status=task_status) class HandshakeError(Exception): ''' There was an error during connection or disconnection with the websocket server. ''' class ConnectionTimeout(HandshakeError): '''There was a timeout when connecting to the websocket server.''' class DisconnectionTimeout(HandshakeError): '''There was a timeout when disconnecting from the websocket server.''' class ConnectionClosed(Exception): ''' A WebSocket operation cannot be completed because the connection is closed or in the process of closing. ''' def __init__(self, reason): ''' Constructor. :param reason: :type reason: CloseReason ''' super().__init__() self.reason = reason def __repr__(self): ''' Return representation. ''' return f'{self.__class__.__name__}<{self.reason}>' class ConnectionRejected(HandshakeError): ''' A WebSocket connection could not be established because the server rejected the connection attempt. ''' def __init__(self, status_code, headers, body): ''' Constructor. :param reason: :type reason: CloseReason ''' super().__init__() #: a 3 digit HTTP status code self.status_code = status_code #: a tuple of 2-tuples containing header key/value pairs self.headers = headers #: an optional ``bytes`` response body self.body = body def __repr__(self): ''' Return representation. ''' return f'{self.__class__.__name__}' class CloseReason: ''' Contains information about why a WebSocket was closed. ''' def __init__(self, code, reason): ''' Constructor. :param int code: :param Optional[str] reason: ''' self._code = code try: self._name = wsframeproto.CloseReason(code).name except ValueError: if 1000 <= code <= 2999: self._name = 'RFC_RESERVED' elif 3000 <= code <= 3999: self._name = 'IANA_RESERVED' elif 4000 <= code <= 4999: self._name = 'PRIVATE_RESERVED' else: self._name = 'INVALID_CODE' self._reason = reason @property def code(self): ''' (Read-only) The numeric close code. ''' return self._code @property def name(self): ''' (Read-only) The human-readable close code. ''' return self._name @property def reason(self): ''' (Read-only) An arbitrary reason string. ''' return self._reason def __repr__(self): ''' Show close code, name, and reason. ''' return f'{self.__class__.__name__}' \ f'' class Future: ''' Represents a value that will be available in the future. ''' def __init__(self): ''' Constructor. ''' self._value = None self._value_event = trio.Event() def set_value(self, value): ''' Set a value, which will notify any waiters. :param value: ''' self._value = value self._value_event.set() async def wait_value(self): ''' Wait for this future to have a value, then return it. :returns: The value set by ``set_value()``. ''' await self._value_event.wait() return self._value class WebSocketRequest: ''' Represents a handshake presented by a client to a server. The server may modify the handshake or leave it as is. The server should call ``accept()`` to finish the handshake and obtain a connection object. ''' def __init__(self, connection, event): ''' Constructor. :param WebSocketConnection connection: :type event: wsproto.events.Request ''' self._connection = connection self._event = event @property def headers(self): ''' HTTP headers represented as a list of (name, value) pairs. :rtype: list[tuple] ''' return self._event.extra_headers @property def path(self): ''' The requested URL path. :rtype: str ''' return self._event.target @property def proposed_subprotocols(self): ''' A tuple of protocols proposed by the client. :rtype: tuple[str] ''' return tuple(self._event.subprotocols) @property def local(self): ''' The connection's local endpoint. :rtype: Endpoint or str ''' return self._connection.local @property def remote(self): ''' The connection's remote endpoint. :rtype: Endpoint or str ''' return self._connection.remote async def accept(self, *, subprotocol=None, extra_headers=None): ''' Accept the request and return a connection object. :param subprotocol: The selected subprotocol for this connection. :type subprotocol: str or None :param extra_headers: A list of 2-tuples containing key/value pairs to send as HTTP headers. :type extra_headers: list[tuple[bytes,bytes]] or None :rtype: WebSocketConnection ''' if extra_headers is None: extra_headers = [] await self._connection._accept(self._event, subprotocol, extra_headers) return self._connection async def reject(self, status_code, *, extra_headers=None, body=None): ''' Reject the handshake. :param int status_code: The 3 digit HTTP status code. In order to be RFC-compliant, this should NOT be 101, and would ideally be an appropriate code in the range 300-599. :param list[tuple[bytes,bytes]] extra_headers: A list of 2-tuples containing key/value pairs to send as HTTP headers. :param body: If provided, this data will be sent in the response body, otherwise no response body will be sent. :type body: bytes or None ''' extra_headers = extra_headers or [] body = body or b'' await self._connection._reject(status_code, extra_headers, body) def _get_stream_endpoint(stream, *, local): ''' Construct an endpoint from a stream. :param trio.Stream stream: :param bool local: If true, return local endpoint. Otherwise return remote. :returns: An endpoint instance or ``repr()`` for streams that cannot be represented as an endpoint. :rtype: Endpoint or str ''' socket, is_ssl = None, False if isinstance(stream, trio.SocketStream): socket = stream.socket elif isinstance(stream, trio.SSLStream): socket = stream.transport_stream.socket is_ssl = True if socket: addr, port, *_ = socket.getsockname() if local else socket.getpeername() endpoint = Endpoint(addr, port, is_ssl) else: endpoint = repr(stream) return endpoint class WebSocketConnection(trio.abc.AsyncResource): ''' A WebSocket connection. ''' 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): ''' Constructor. Generally speaking, users are discouraged from directly instantiating a ``WebSocketConnection`` and should instead use one of the convenience functions in this module, e.g. ``open_websocket()`` or ``serve_websocket()``. This class has some tricky internal logic and timing that depends on whether it is an instance of a client connection or a server connection. The convenience functions handle this complexity for you. :param SocketStream stream: :param ws_connection wsproto.WSConnection: :param str host: The hostname to send in the HTTP request headers. Only used for client connections. :param str path: The URL path for this connection. :param list client_subprotocols: A list of desired subprotocols. Only used for client connections. :param list[tuple[bytes,bytes]] client_extra_headers: Extra headers to send with the connection request. Only used for client connections. :param int message_queue_size: The maximum number of messages that will be buffered in the library's internal message queue. :param int max_message_size: The maximum message size as measured by ``len()``. If a message is received that is larger than this size, then the connection is closed with code 1009 (Message Too Big). ''' # NOTE: The implementation uses _close_reason for more than an advisory # purpose. It's critical internal state, indicating when the # connection is closed or closing. self._close_reason: Optional[CloseReason] = None self._id = next(self.__class__.CONNECTION_ID) self._stream = stream self._stream_lock = trio.StrictFIFOLock() self._wsproto = ws_connection self._message_size = 0 self._message_parts: List[Union[bytes, str]] = [] self._max_message_size = max_message_size self._reader_running = True if ws_connection.client: self._initial_request: Optional[Request] = Request(host=host, target=path, subprotocols=client_subprotocols, extra_headers=client_extra_headers or []) else: self._initial_request = None self._path = path self._subprotocol: Optional[str] = None self._handshake_headers = tuple() self._reject_status = 0 self._reject_headers = tuple() self._reject_body = b'' self._send_channel, self._recv_channel = trio.open_memory_channel( message_queue_size) self._pings = OrderedDict() # Set when the server has received a connection request event. This # future is never set on client connections. self._connection_proposal = Future() # Set once the WebSocket open handshake takes place, i.e. # ConnectionRequested for server or ConnectedEstablished for client. self._open_handshake = trio.Event() # Set once a WebSocket closed handshake takes place, i.e after a close # frame has been sent and a close frame has been received. self._close_handshake = trio.Event() # Set upon receiving CloseConnection from peer. # Used to test close race conditions between client and server. self._for_testing_peer_closed_connection = trio.Event() @property def closed(self): ''' (Read-only) The reason why the connection was or is being closed, else ``None``. :rtype: Optional[CloseReason] ''' return self._close_reason @property def is_client(self): ''' (Read-only) Is this a client instance? ''' return self._wsproto.client @property def is_server(self): ''' (Read-only) Is this a server instance? ''' return not self._wsproto.client @property def local(self): ''' The local endpoint of the connection. :rtype: Endpoint or str ''' return _get_stream_endpoint(self._stream, local=True) @property def remote(self): ''' The remote endpoint of the connection. :rtype: Endpoint or str ''' return _get_stream_endpoint(self._stream, local=False) @property def path(self): ''' The requested URL path. For clients, this is set when the connection is instantiated. For servers, it is set after the handshake completes. :rtype: str ''' return self._path @property def subprotocol(self): ''' (Read-only) The negotiated subprotocol, or ``None`` if there is no subprotocol. This is only valid after the opening handshake is complete. :rtype: str or None ''' return self._subprotocol @property def handshake_headers(self): ''' The HTTP headers that were sent by the remote during the handshake, stored as 2-tuples containing key/value pairs. Header keys are always lower case. :rtype: tuple[tuple[str,str]] ''' return self._handshake_headers async def aclose(self, code=1000, reason=None): # pylint: disable=arguments-differ ''' Close the WebSocket connection. This sends a closing frame and suspends until the connection is closed. After calling this method, any further I/O on this WebSocket (such as ``get_message()`` or ``send_message()``) will raise ``ConnectionClosed``. This method is idempotent: it may be called multiple times on the same connection without any errors. :param int code: A 4-digit code number indicating the type of closure. :param str reason: An optional string describing the closure. ''' with _preserve_current_exception(): await self._aclose(code, reason) async def _aclose(self, code, reason): if self._close_reason: # Per AsyncResource interface, calling aclose() on a closed resource # should succeed. return try: if self._wsproto.state == ConnectionState.OPEN: # Our side is initiating the close, so send a close connection # event to peer, while setting the local close reason to normal. self._close_reason = CloseReason(1000, None) await self._send(CloseConnection(code=code, reason=reason)) elif self._wsproto.state in (ConnectionState.CONNECTING, ConnectionState.REJECTING): self._close_handshake.set() # TODO: shouldn't the receive channel be closed earlier, so that # get_message() during send of the CloseConneciton event fails? await self._recv_channel.aclose() await self._close_handshake.wait() except ConnectionClosed: # If _send() raised ConnectionClosed, then we can bail out. pass finally: # If cancelled during WebSocket close, make sure that the underlying # stream is closed. await self._close_stream() async def get_message(self): ''' Receive the next WebSocket message. If no message is available immediately, then this function blocks until a message is ready. If the remote endpoint closes the connection, then the caller can still get messages sent prior to closing. Once all pending messages have been retrieved, additional calls to this method will raise ``ConnectionClosed``. If the local endpoint closes the connection, then pending messages are discarded and calls to this method will immediately raise ``ConnectionClosed``. :rtype: str or bytes :raises ConnectionClosed: if the connection is closed. ''' try: message = await self._recv_channel.receive() except (trio.ClosedResourceError, trio.EndOfChannel): raise ConnectionClosed(self._close_reason) from None return message async def ping(self, payload=None): ''' Send WebSocket ping to remote endpoint and wait for a correspoding pong. Each in-flight ping must include a unique payload. This function sends the ping and then waits for a corresponding pong from the remote endpoint. *Note: If the remote endpoint recieves multiple pings, it is allowed to send a single pong. Therefore, the order of calls to ``ping()`` is tracked, and a pong will wake up its corresponding ping as well as all previous in-flight pings.* :param payload: The payload to send. If ``None`` then a random 32-bit payload is created. :type payload: bytes or None :raises ConnectionClosed: if connection is closed. :raises ValueError: if ``payload`` is identical to another in-flight ping. ''' if self._close_reason: raise ConnectionClosed(self._close_reason) if payload in self._pings: raise ValueError(f'Payload value {payload} is already in flight.') if payload is None: payload = struct.pack('!I', random.getrandbits(32)) event = trio.Event() self._pings[payload] = event await self._send(Ping(payload=payload)) await event.wait() async def pong(self, payload=None): ''' Send an unsolicted pong. :param payload: The pong's payload. If ``None``, then no payload is sent. :type payload: bytes or None :raises ConnectionClosed: if connection is closed ''' if self._close_reason: raise ConnectionClosed(self._close_reason) await self._send(Pong(payload=payload)) async def send_message(self, message): ''' Send a WebSocket message. :param message: The message to send. :type message: str or bytes :raises ConnectionClosed: if connection is closed, or being closed ''' if self._close_reason: raise ConnectionClosed(self._close_reason) if isinstance(message, str): event = TextMessage(data=message) elif isinstance(message, bytes): event = BytesMessage(data=message) else: raise ValueError('message must be str or bytes') await self._send(event) def __str__(self): ''' Connection ID and type. ''' type_ = 'client' if self.is_client else 'server' return f'{type_}-{self._id}' async def _accept(self, request, subprotocol, extra_headers): ''' Accept the handshake. This method is only applicable to server-side connections. :param wsproto.events.Request request: :param subprotocol: :type subprotocol: str or None :param list[tuple[bytes,bytes]] extra_headers: A list of 2-tuples containing key/value pairs to send as HTTP headers. ''' self._subprotocol = subprotocol self._path = request.target await self._send(AcceptConnection(subprotocol=self._subprotocol, extra_headers=extra_headers)) self._open_handshake.set() async def _reject(self, status_code, headers, body): ''' Reject the handshake. :param int status_code: The 3 digit HTTP status code. In order to be RFC-compliant, this must not be 101, and should be an appropriate code in the range 300-599. :param list[tuple[bytes,bytes]] headers: A list of 2-tuples containing key/value pairs to send as HTTP headers. :param bytes body: An optional response body. ''' if body: headers.append(('Content-length', str(len(body)).encode('ascii'))) reject_conn = RejectConnection(status_code=status_code, headers=headers, has_body=bool(body)) await self._send(reject_conn) if body: reject_body = RejectData(data=body) await self._send(reject_body) self._close_reason = CloseReason(1006, 'Rejected WebSocket handshake') self._close_handshake.set() async def _abort_web_socket(self): ''' If a stream is closed outside of this class, e.g. due to network conditions or because some other code closed our stream object, then we cannot perform the close handshake. We just need to clean up internal state. ''' close_reason = wsframeproto.CloseReason.ABNORMAL_CLOSURE if self._wsproto.state == ConnectionState.OPEN: self._wsproto.send(CloseConnection(code=close_reason.value)) if self._close_reason is None: await self._close_web_socket(close_reason) self._reader_running = False # We didn't really handshake, but we want any task waiting on this event # (e.g. self.aclose()) to resume. self._close_handshake.set() async def _close_stream(self): ''' Close the TCP connection. ''' self._reader_running = False try: with _preserve_current_exception(): await self._stream.aclose() except trio.BrokenResourceError: # This means the TCP connection is already dead. pass async def _close_web_socket(self, code, reason=None): ''' Mark the WebSocket as closed. Close the message channel so that if any tasks are suspended in get_message(), they will wake up with a ConnectionClosed exception. ''' self._close_reason = CloseReason(code, reason) exc = ConnectionClosed(self._close_reason) logger.debug('%s websocket closed %r', self, exc) await self._send_channel.aclose() async def _get_request(self): ''' Return a proposal for a WebSocket handshake. This method can only be called on server connections and it may only be called one time. :rtype: WebSocketRequest ''' if not self.is_server: raise RuntimeError('This method is only valid for server connections.') if self._connection_proposal is None: raise RuntimeError('No proposal available. Did you call this method' ' multiple times or at the wrong time?') proposal = await self._connection_proposal.wait_value() self._connection_proposal = None return proposal async def _handle_request_event(self, event): ''' Handle a connection request. This method is async even though it never awaits, because the event dispatch requires an async function. :param event: ''' proposal = WebSocketRequest(self, event) self._connection_proposal.set_value(proposal) async def _handle_accept_connection_event(self, event): ''' Handle an AcceptConnection event. :param wsproto.eventsAcceptConnection event: ''' self._subprotocol = event.subprotocol self._handshake_headers = tuple(event.extra_headers) self._open_handshake.set() async def _handle_reject_connection_event(self, event): ''' Handle a RejectConnection event. :param event: ''' self._reject_status = event.status_code self._reject_headers = tuple(event.headers) if not event.has_body: raise ConnectionRejected(self._reject_status, self._reject_headers, body=None) async def _handle_reject_data_event(self, event): ''' Handle a RejectData event. :param event: ''' self._reject_body += event.data if event.body_finished: raise ConnectionRejected(self._reject_status, self._reject_headers, body=self._reject_body) async def _handle_close_connection_event(self, event): ''' Handle a close event. :param wsproto.events.CloseConnection event: ''' if self._wsproto.state == ConnectionState.REMOTE_CLOSING: # Set _close_reason in advance, so that send_message() will raise # ConnectionClosed during the close handshake. self._close_reason = CloseReason(event.code, event.reason or None) self._for_testing_peer_closed_connection.set() await self._send(event.response()) await self._close_web_socket(event.code, event.reason or None) self._close_handshake.set() # RFC: "When a server is instructed to Close the WebSocket Connection # it SHOULD initiate a TCP Close immediately, and when a client is # instructed to do the same, it SHOULD wait for a TCP Close from the # server." if self.is_server: await self._close_stream() async def _handle_message_event(self, event): ''' Handle a message event. :param event: :type event: wsproto.events.BytesMessage or wsproto.events.TextMessage ''' self._message_size += len(event.data) self._message_parts.append(event.data) if self._message_size > self._max_message_size: err = f'Exceeded maximum message size: {self._max_message_size} bytes' self._message_size = 0 self._message_parts = [] self._close_reason = CloseReason(1009, err) await self._send(CloseConnection(code=1009, reason=err)) await self._recv_channel.aclose() self._reader_running = False elif event.message_finished: msg = (b'' if isinstance(event, BytesMessage) else '') \ .join(self._message_parts) self._message_size = 0 self._message_parts = [] try: await self._send_channel.send(msg) except (trio.ClosedResourceError, trio.BrokenResourceError): # The receive channel is closed, probably because somebody # called ``aclose()``. We don't want to abort the reader task, # and there's no useful cleanup that we can do here. pass async def _handle_ping_event(self, event): ''' Handle a PingReceived event. Wsproto queues a pong frame automatically, so this handler just needs to send it. :param wsproto.events.Ping event: ''' logger.debug('%s ping %r', self, event.payload) await self._send(event.response()) async def _handle_pong_event(self, event): ''' Handle a PongReceived event. When a pong is received, check if we have any ping requests waiting for this pong response. If the remote endpoint skipped any earlier pings, then we wake up those skipped pings, too. This function is async even though it never awaits, because the other event handlers are async, too, and event dispatch would be more complicated if some handlers were sync. :param event: ''' payload = bytes(event.payload) try: event = self._pings[payload] except KeyError: # We received a pong that doesn't match any in-flight pongs. Nothing # we can do with it, so ignore it. return while self._pings: key, event = self._pings.popitem(0) skipped = ' [skipped] ' if payload != key else ' ' logger.debug('%s pong%s%r', self, skipped, key) event.set() if payload == key: break async def _reader_task(self): ''' A background task that reads network data and generates events. ''' handlers = { AcceptConnection: self._handle_accept_connection_event, BytesMessage: self._handle_message_event, CloseConnection: self._handle_close_connection_event, Ping: self._handle_ping_event, Pong: self._handle_pong_event, RejectConnection: self._handle_reject_connection_event, RejectData: self._handle_reject_data_event, Request: self._handle_request_event, TextMessage: self._handle_message_event, } # Clients need to initiate the opening handshake. if self._initial_request: try: await self._send(self._initial_request) except ConnectionClosed: self._reader_running = False async with self._send_channel: while self._reader_running: # Process events. for event in self._wsproto.events(): event_type = type(event) try: handler = handlers[event_type] logger.debug('%s received event: %s', self, event_type) await handler(event) except KeyError: logger.warning('%s received unknown event type: "%s"', self, event_type) except ConnectionClosed: self._reader_running = False break # Get network data. try: data = await self._stream.receive_some(RECEIVE_BYTES) except (trio.BrokenResourceError, trio.ClosedResourceError): await self._abort_web_socket() break if len(data) == 0: logger.debug('%s received zero bytes (connection closed)', self) # If TCP closed before WebSocket, then record it as an abnormal # closure. if self._wsproto.state != ConnectionState.CLOSED: await self._abort_web_socket() break logger.debug('%s received %d bytes', self, len(data)) if self._wsproto.state != ConnectionState.CLOSED: try: self._wsproto.receive_data(data) except wsproto.utilities.RemoteProtocolError as err: logger.debug('%s remote protocol error: %s', self, err) if err.event_hint: await self._send(err.event_hint) await self._close_stream() logger.debug('%s reader task finished', self) async def _send(self, event): ''' Send an event to the remote WebSocket. The reader task and one or more writers might try to send messages at the same time, so this method uses an internal lock to serialize requests to send data. :param wsproto.events.Event event: ''' data = self._wsproto.send(event) async with self._stream_lock: logger.debug('%s sending %d bytes', self, len(data)) try: await self._stream.send_all(data) except (trio.BrokenResourceError, trio.ClosedResourceError): await self._abort_web_socket() raise ConnectionClosed(self._close_reason) from None class Endpoint: ''' Represents a connection endpoint. ''' def __init__(self, address, port, is_ssl): #: IP address :class:`ipaddress.ip_address` self.address = ip_address(address) #: TCP port self.port = port #: Whether SSL is in use self.is_ssl = is_ssl @property def url(self): ''' Return a URL representation of a TCP endpoint, e.g. ``ws://127.0.0.1:80``. ''' scheme = 'wss' if self.is_ssl else 'ws' if (self.port == 80 and not self.is_ssl) or \ (self.port == 443 and self.is_ssl): port_str = '' else: port_str = ':' + str(self.port) if self.address.version == 4: return f'{scheme}://{self.address}{port_str}' return f'{scheme}://[{self.address}]{port_str}' def __repr__(self): ''' Return endpoint info as string. ''' return f'Endpoint(address="{self.address}", port={self.port}, is_ssl={self.is_ssl})' class WebSocketServer: ''' WebSocket server. The server class handles incoming connections on one or more ``Listener`` objects. For each incoming connection, it creates a ``WebSocketConnection`` instance and starts some background tasks, ''' def __init__(self, handler, listeners, *, handler_nursery=None, message_queue_size=MESSAGE_QUEUE_SIZE, max_message_size=MAX_MESSAGE_SIZE, connect_timeout=CONN_TIMEOUT, disconnect_timeout=CONN_TIMEOUT): ''' Constructor. Note that if ``host`` is ``None`` and ``port`` is zero, then you may get multiple listeners that have _different port numbers!_ See the ``listeners`` property. :param handler: the async function called with a :class:`WebSocketRequest` on each new connection. The call will be made once the HTTP handshake completes, which notably implies that the connection's `path` property will be valid. :param listeners: The WebSocket will be served on each of the listeners. :param handler_nursery: An optional nursery to spawn connection tasks inside of. If ``None``, then a new nursery will be created internally. :param float connect_timeout: The number of seconds to wait for a client to finish connection handshake before timing out. :param float disconnect_timeout: The number of seconds to wait for a client to finish the closing handshake before timing out. ''' if len(listeners) == 0: raise ValueError('Listeners must contain at least one item.') self._handler = handler self._handler_nursery = handler_nursery self._listeners = listeners self._message_queue_size = message_queue_size self._max_message_size = max_message_size self._connect_timeout = connect_timeout self._disconnect_timeout = disconnect_timeout @property def port(self): """Returns the requested or kernel-assigned port number. In the case of kernel-assigned port (requested with port=0 in the constructor), the assigned port will be reflected after calling starting the `listen` task. (Technically, once listen reaches the "started" state.) This property only works if you have a single listener, and that listener must be socket-based. """ if len(self._listeners) > 1: raise RuntimeError('Cannot get port because this server has' ' more than 1 listeners.') listener = self.listeners[0] try: return listener.port except AttributeError: raise RuntimeError(f'This socket does not have a port: {repr(listener)}') from None @property def listeners(self): ''' Return a list of listener metadata. Each TCP listener is represented as an ``Endpoint`` instance. Other listener types are represented by their ``repr()``. :returns: Listeners :rtype list[Endpoint or str]: ''' listeners = [] for listener in self._listeners: socket, is_ssl = None, False if isinstance(listener, trio.SocketListener): socket = listener.socket elif isinstance(listener, trio.SSLListener): socket = listener.transport_listener.socket is_ssl = True if socket: sockname = socket.getsockname() listeners.append(Endpoint(sockname[0], sockname[1], is_ssl)) else: listeners.append(repr(listener)) return listeners async def run(self, *, task_status=trio.TASK_STATUS_IGNORED): ''' Start serving incoming connections requests. This method supports the Trio nursery start protocol: ``server = await nursery.start(server.run, …)``. It will block until the server is accepting connections and then return a :class:`WebSocketServer` object. :param task_status: Part of the Trio nursery start protocol. :returns: This method never returns unless cancelled. ''' async with trio.open_nursery() as nursery: serve_listeners = partial(trio.serve_listeners, self._handle_connection, self._listeners, handler_nursery=self._handler_nursery) await nursery.start(serve_listeners) logger.debug('Listening on %s', ','.join([str(l) for l in self.listeners])) task_status.started(self) await trio.sleep_forever() async def _handle_connection(self, stream): ''' Handle an incoming connection by spawning a connection background task and a handler task inside a new nursery. :param stream: :type stream: trio.abc.Stream ''' async with trio.open_nursery() as nursery: connection = WebSocketConnection(stream, WSConnection(ConnectionType.SERVER), message_queue_size=self._message_queue_size, max_message_size=self._max_message_size) nursery.start_soon(connection._reader_task) with trio.move_on_after(self._connect_timeout) as connect_scope: request = await connection._get_request() if connect_scope.cancelled_caught: nursery.cancel_scope.cancel() await stream.aclose() return try: await self._handler(request) finally: with trio.move_on_after(self._disconnect_timeout): # aclose() will shut down the reader task even if it's # cancelled: await connection.aclose() python-trio-websocket-0.11.1/trio_websocket/_version.py000066400000000000000000000000271450466364500233070ustar00rootroot00000000000000__version__ = '0.11.1'