diff --git a/README.md b/README.md index 7c42ac8..6c9912f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,34 @@ +# Trio WebSocket + +This library implements [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) ![MIT License](https://img.shields.io/github/license/HyperionGray/trio-websocket.svg?style=flat-square) [![Build Status](https://img.shields.io/travis/HyperionGray/trio-websocket.svg?style=flat-square)](https://travis-ci.org/HyperionGray/trio-websocket) [![Coverage](https://img.shields.io/coveralls/github/HyperionGray/trio-websocket.svg?style=flat-square)](https://coveralls.io/github/HyperionGray/trio-websocket?branch=master) - -# Trio WebSocket - -This project implements [the WebSocket -protocol](https://tools.ietf.org/html/rfc6455). 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 -implements the I/O using [Trio](https://trio.readthedocs.io/en/latest/). +[![Read the Docs](https://img.shields.io/readthedocs/trio-websocket.svg)](https://trio-websocket.readthedocs.io) ## Installation -`trio-websocket` requires Python v3.5 or greater. To install from PyPI: +This library requires Python 3.5 or greater. To install from PyPI: pip install trio-websocket -If you want to help develop `trio-websocket`, clone [the -repository](https://github.com/hyperiongray/trio-websocket) and run this command -from the repository root: - - pip install --editable .[dev] +## Client Example -## Sample client - -The following example demonstrates opening a WebSocket by URL. The connection -may also be opened with `open_websocket(…)`, which takes a host, port, and -resource as arguments. +This example demonstrates how to open a WebSocket URL: ```python import trio @@ -37,25 +37,25 @@ from trio_websocket import open_websocket_url async def main(): try: - async with open_websocket_url('ws://localhost/foo') as ws: + 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) ``` -A more detailed example is in `examples/client.py`. **Note:** if you want to run -this example client with SSL, you'll need to install the `trustme` module from -PyPI (installed automtically if you used the `[dev]` extras when installing -`trio-websocket`) and then generate a self-signed certificate by running -`example/generate-cert.py`. +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. -## Sample server +## 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. +incoming connections. This example demonstrates an "echo server" that replies to +each incoming message with an identical outgoing message. ```python import trio @@ -76,181 +76,7 @@ async def main(): trio.run(main) ``` -A longer example is in `examples/server.py`. **See the note above about using -SSL with the example client.** - -## Heartbeat recipe - -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. - -```python -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://localhost/foo') 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 `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. - -**Note that the WebSocket RFC does not require a WebSocket to send a pong for each -ping:** - -> If an endpoint receives a Ping frame and has not yet sent Pong frame(s) in -> response to previous Ping frame(s), the endpoint MAY elect to send a Pong -> frame for only the most recently processed Ping frame. - -Therefore, if you have multiple pings in flight at the same time, you may not -get an equal number of pongs in response. The simplest strategy for dealing with -this is to only have one ping in flight at a time, as seen in the example above. -As an alternative, you can send a `bytes` payload with each ping. The server -will return the payload with the pong: - -```python -await ws.ping(b'my payload') -pong == await ws.wait_pong() -assert pong == b'my payload' -``` - -You may want to embed a nonce or counter in the payload in order to correlate -pong events to the pings you have sent. - -## Unit Tests - -Unit tests are written in the pytest style. You must install the development -dependencies as described in the installation section above. The -``--cov=trio_websocket`` flag turns on code coverage. - - $ 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/mhaase/code/trio-websocket, inifile: pytest.ini - plugins: trio-0.5.0, cov-2.6.0 - collected 21 items - - tests/test_connection.py ..................... [100%] - - --- coverage: platform linux, python 3.6.6-final-0 --- - Name Stmts Miss Cover - ------------------------------------------------ - trio_websocket/__init__.py 297 40 87% - trio_websocket/_channel.py 140 52 63% - trio_websocket/version.py 1 0 100% - ------------------------------------------------ - TOTAL 438 92 79% - - === 21 passed in 0.54 seconds === - -## Integration Testing with Autobahn - -The Autobahn Test Suite contains over 500 integration tests for WebSocket -servers and clients. These test suites are contained in a -[Docker](https://www.docker.com/) container. You will need to install Docker -before you can run these integration tests. - -### Client 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 - -### Server Tests - -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" \ - --name autobahn \ - crossbario/autobahn-testsuite \ - /usr/local/bin/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. - -## Release Process - -* Remove `-dev` suffix from `version.py`. -* Commit and push version change. -* Create and push tag, e.g. `git tag 1.0.0 && git push origin 1.0.0`. -* Clean build directory: `rm -fr dist` -* Build package: `python setup.py sdist` -* Upload to PyPI: `twine upload dist/*` -* Increment version and add `-dev` suffix. -* Commit and push version change. +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. diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..d4e11e5 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +_build + diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..298ea9e --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +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) \ No newline at end of file diff --git a/docs/_static/README.txt b/docs/_static/README.txt new file mode 100644 index 0000000..d00c8e4 --- /dev/null +++ b/docs/_static/README.txt @@ -0,0 +1,3 @@ +This is just a placeholder file because this project doesn't +have any static assets. + diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..9e3bef4 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,80 @@ +API +=== + +.. 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:: subprotocol + .. autoattribute:: url + .. automethod:: accept + +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:: is_closed + .. autoattribute:: close_reason + .. autoattribute:: is_client + .. autoattribute:: is_server + .. autoattribute:: path + .. autoattribute:: subprotocol + + 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 diff --git a/docs/clients.rst b/docs/clients.rst new file mode 100644 index 0000000..94daf29 --- /dev/null +++ b/docs/clients.rst @@ -0,0 +1,69 @@ +.. _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 diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..a5c4af5 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,182 @@ +# -*- 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' + +import trio_websocket.version +version = trio_websocket.version.__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), + 'yarl': ('https://yarl.readthedocs.io/en/stable/', None), +} diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..c63a0a6 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,181 @@ +Contributing +============ + +.. _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:HyperionGray/trio-websocket.git + $ cd trio-websocket + $ python3 -m venv venv + $ source venv/bin/activate + (venv) $ pip install --editable .[dev] + + +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:: + + (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, go into the +documentation directory and run this command:: + + $ make html + +The finished documentation can be found in ``/docs/_build/``. It is published +automatically to `Read The Docs `__. + +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" \ + --name autobahn \ + crossbario/autobahn-testsuite \ + /usr/local/bin/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. + +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 `Travis CI `__ to + finish building and ensure that the build is successful. +6. Ensure that the working copy is in a clean state, e.g. ``git status`` shows + no changes. +7. Clean build directory: ``rm -fr dist`` +8. Build package: ``python setup.py sdist`` +9. Upload to PyPI: ``twine upload dist/*`` +10. 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``. +11. Commit and push ``version.py``. diff --git a/docs/credits.rst b/docs/credits.rst new file mode 100644 index 0000000..76b970a --- /dev/null +++ b/docs/credits.rst @@ -0,0 +1,13 @@ +Credits +======= + +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 diff --git a/docs/getting_started.rst b/docs/getting_started.rst new file mode 100644 index 0000000..5fc23c8 --- /dev/null +++ b/docs/getting_started.rst @@ -0,0 +1,96 @@ +Getting 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`. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..ae3933c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,38 @@ +Trio 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/HyperionGray/trio-websocket.svg?style=flat-square + :alt: MIT License +.. image:: https://img.shields.io/travis/HyperionGray/trio-websocket.svg?style=flat-square + :alt: Build Status + :target: https://travis-ci.org/HyperionGray/trio-websocket +.. image:: https://img.shields.io/coveralls/github/HyperionGray/trio-websocket.svg?style=flat-square + :alt: Coverage + :target: https://coveralls.io/github/HyperionGray/trio-websocket?branch=master + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + getting_started + clients + servers + api + recipes + contributing + credits diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..27f573b --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@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 diff --git a/docs/recipes.rst b/docs/recipes.rst new file mode 100644 index 0000000..19ad0dd --- /dev/null +++ b/docs/recipes.rst @@ -0,0 +1,61 @@ +Recipes +======= + +.. 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://localhost/foo') 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. diff --git a/docs/servers.rst b/docs/servers.rst new file mode 100644 index 0000000..b01d078 --- /dev/null +++ b/docs/servers.rst @@ -0,0 +1,57 @@ +.. _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. + +.. 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 diff --git a/setup.py b/setup.py index 5bf081b..ee29e43 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ 'async_generator', 'attrs>=18.2', 'ipaddress', + 'sphinxcontrib-trio', 'trio>=0.9,<0.10.0', 'wsaccel', 'wsproto>=0.12.0', @@ -50,6 +51,8 @@ 'pytest>=3.6', 'pytest-cov', 'pytest-trio>=0.5.0', + 'sphinx', + 'sphinx_rtd_theme', 'trustme', ], }, diff --git a/tests/test_connection.py b/tests/test_connection.py index 6668e09..33c194e 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -276,7 +276,7 @@ async def ping_and_catch(): nonlocal exc_count try: await echo_conn.ping(b'A') - except Exception: + except ValueError: exc_count += 1 async with echo_conn: async with trio.open_nursery() as nursery: diff --git a/trio_websocket/__init__.py b/trio_websocket/__init__.py index c761f89..38977c4 100644 --- a/trio_websocket/__init__.py +++ b/trio_websocket/__init__.py @@ -28,18 +28,17 @@ async def open_websocket(host, port, resource, *, use_ssl, subprotocols=None): ''' Open a WebSocket client connection to a host. - This function is an async context manager that connects before entering the - context manager and disconnects after leaving. It yields a - `WebSocketConnection` instance. - - ``use_ssl`` can be an ``SSLContext`` object, or if it's ``True``, then a - default SSL context will be created. If it is ``False``, then SSL will not - be used at all. - - :param str host: the host to connect to - :param int port: the port to connect to - :param str resource: the resource a.k.a. path - :param use_ssl: a bool or SSLContext + 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 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. + :type use_ssl: bool or ssl.SSLContext :param subprotocols: An iterable of strings representing preferred subprotocols. ''' @@ -53,21 +52,22 @@ async def open_websocket(host, port, resource, *, use_ssl, subprotocols=None): async def connect_websocket(nursery, host, port, resource, *, use_ssl, subprotocols=None): ''' - Return a WebSocket client connection to a host. - - Most users should use ``open_websocket(…)`` instead of this function. This - function is not an async context manager, and it requires a nursery argument - for the connection's background task[s]. The caller is responsible for - closing the connection. - - :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 a.k.a. path - :param use_ssl: a bool or SSLContext - :rtype: WebSocketConnection + 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. + :type use_ssl: bool or ssl.SSLContext :param subprotocols: An iterable of strings representing preferred subprotocols. + :rtype: WebSocketConnection ''' if use_ssl == True: ssl_context = ssl.create_default_context() @@ -101,16 +101,14 @@ def open_websocket_url(url, ssl_context=None, *, subprotocols=None): ''' Open a WebSocket client connection to a URL. - This function is an async context manager that connects when entering the - context manager and disconnects when exiting. It yields a - `WebSocketConnection` instance. + This async context manager connects when entering the context manager and + disconnects when exiting. It yields a + :class:`WebSocketConnection` instance. - If ``ssl_context`` is ``None`` and the URL scheme is ``wss:``, then a - default SSL context will be created. It is an error to pass an SSL context - for ``ws:`` URLs. - - :param str url: a WebSocket URL - :param ssl_context: optional ``SSLContext`` used for ``wss:`` URLs + :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. ''' @@ -122,22 +120,21 @@ def open_websocket_url(url, ssl_context=None, *, subprotocols=None): async def connect_websocket_url(nursery, url, ssl_context=None, *, subprotocols=None): ''' - Return a WebSocket client connection to a URL. + Return an open WebSocket client connection to a URL. - Most users should use ``open_websocket_url(…)`` instead of this function. - This function is not an async context manager, and it requires a nursery - argument for the connection's background task[s]. + This function is used to specify a custom nursery to run connection + background tasks in. The caller is responsible for closing the connection. - If ``ssl_context`` is ``None`` and the URL scheme is ``wss:``, then a - default SSL context will be created. It is an error to pass an SSL context - for ``ws:`` URLs. + If you don't need a custom nursery, you should probably use + :func:`open_websocket_url` instead. - :param str url: a WebSocket URL - :param ssl_context: optional ``SSLContext`` used for ``wss:`` URLs - :param nursery: a Trio nursery to run background tasks in - :rtype: WebSocketConnection + :param str url: A WebSocket URL. + :param ssl_context: Optional SSL context used for ``wss:`` URLs. + :type ssl_context: ssl.SSLContext or None + :param nursery: A nursery to run background tasks in. :param subprotocols: An iterable of strings representing preferred subprotocols. + :rtype: WebSocketConnection ''' host, port, resource, ssl_context = _url_to_host(url, ssl_context) return await connect_websocket(nursery, host, port, resource, @@ -152,9 +149,9 @@ def _url_to_host(url, ssl_context): 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 - :param ssl_context: ``SSLContext`` or ``None`` - :return: tuple of ``(host, port, resource, ssl_context)`` + :param str url: A WebSocket URL. + :type ssl_context: ssl.SSLContext or None + :returns: A tuple of ``(host, port, resource, ssl_context)``. ''' url = URL(url) if url.scheme not in ('ws', 'wss'): @@ -169,13 +166,14 @@ def _url_to_host(url, ssl_context): async def wrap_client_stream(nursery, stream, host, resource, *, subprotocols=None): ''' - Wrap an arbitrary stream in a client-side ``WebSocketConnection``. + Wrap an arbitrary stream in a WebSocket connection. - This is a low-level function only needed in rare cases. Most users should - call ``open_websocket()`` or ``open_websocket_url()``. + 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. @@ -195,17 +193,13 @@ async def wrap_server_stream(nursery, stream): ''' Wrap an arbitrary stream in a server-side WebSocket. - The object returned is a ``WebSocketRequest``, which indicates the client's - proposed handshake. Call ``accept()`` on this object to obtain a - ``WebSocketConnection``. + This is a low-level function only needed in rare cases. In most cases, you + should use :func:`serve_websocket`. - This is a low-level function only needed in rare cases. Most users should - call ``serve_websocket()`. - - :param nursery: A Trio nursery to run background tasks in. - :param stream: A Trio stream to be wrapped. - :param task_status: part of Trio nursery start protocol - :rtype: WebSocketRequest + :param nursery: A nursery to run background tasks in. + :param stream: A stream to be wrapped. + :type stream: trio.abc.Stream + :rtype: WebSocketConnection ''' wsproto = wsconnection.WSConnection(wsconnection.SERVER) connection = WebSocketConnection(stream, wsproto) @@ -221,28 +215,26 @@ async def serve_websocket(handler, host, port, ssl_context, *, 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 the WebSocketServer object. + 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!_ + multiple listeners that have *different port numbers!* - :param handler: The async function called with the corresponding - WebSocketConnection 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 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 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: SSLContext or None + :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 task_status: part of Trio nursery start protocol - :returns: This function never returns unless cancelled. + :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) @@ -264,7 +256,8 @@ def __init__(self, reason): ''' Constructor. - :param CloseReason reason: + :param reason: + :type reason: CloseReason ''' self.reason = reason @@ -298,17 +291,17 @@ def __init__(self, code, reason): @property def code(self): - ''' The numeric close code. ''' + ''' (Read-only) The numeric close code. ''' return self._code @property def name(self): - ''' The human-readable close code. ''' + ''' (Read-only) The human-readable close code. ''' return self._name @property def reason(self): - ''' An arbitrary reason string. ''' + ''' (Read-only) An arbitrary reason string. ''' return self._reason def __repr__(self): @@ -365,16 +358,17 @@ def __init__(self, accept_fn, event): @property def headers(self): ''' - A list of headers represented as (name, value) pairs. + (Read-only) HTTP headers represented as a list of + (name, value) pairs. - :rtype: list + :rtype: list[tuple] ''' return self._event.h11request.headers @property def proposed_subprotocols(self): ''' - A tuple of protocols proposed by the client. + (Read-only) A tuple of protocols proposed by the client. :rtype: tuple[str] ''' @@ -383,7 +377,7 @@ def proposed_subprotocols(self): @property def subprotocol(self): ''' - The selected protocol. Defaults to ``None``. + (Read/Write) The selected protocol. Defaults to ``None``. :rtype: str or None ''' @@ -401,10 +395,10 @@ def subprotocol(self, value): @property def url(self): ''' - The requested URL. Typically this URL does not contain a scheme, host, - or port. + (Read-only) The requested URL. Typically this URL does not contain a + scheme, host, or port. - :rtype yarl.URL: + :rtype: yarl.URL ''' return URL(self._event.h11request.target.decode('ascii')) @@ -466,34 +460,38 @@ def __init__(self, stream, wsproto, *, path=None): @property def close_reason(self): ''' - A ``CloseReason`` object indicating why the connection was closed. + (Read-only) The reason why the connection was closed, or ``None`` if the + connection is still open. + + :rtype: CloseReason ''' return self._close_reason @property def is_closed(self): - ''' A boolean indicating whether the WebSocket is closed. ''' + ''' (Read-only) A boolean indicating whether the WebSocket is closed. ''' return self._close_reason is not None @property def is_client(self): - ''' Is this a client instance? ''' + ''' (Read-only) Is this a client instance? ''' return self._wsproto.client @property def is_server(self): - ''' Is this a server instance? ''' + ''' (Read-only) Is this a server instance? ''' return not self._wsproto.client @property def path(self): - """Returns the path from the HTTP handshake.""" + ''' (Read-only) The path from the HTTP handshake. ''' return self._path @property def subprotocol(self): ''' - Returns the negotiated subprotocol or ``None``. + (Read-only) The negotiated subprotocol, or ``None`` if there is no + subprotocol. This is only valid after the opening handshake is complete. @@ -510,9 +508,11 @@ async def aclose(self, code=1000, reason=None): ``get_message()`` or ``send_message()``) will raise ``ConnectionClosed``. - :param int code: - :param str reason: - :raises ConnectionClosed: if connection is already closed + 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. ''' if self._close_reason: # Per AsyncResource interface, calling aclose() on a closed resource @@ -529,13 +529,14 @@ async def aclose(self, code=1000, reason=None): async def get_message(self): ''' - Return the next WebSocket message. + Receive the next WebSocket message. - Suspends until a message is available. Raises ``ConnectionClosed`` if - the connection is already closed or closes while waiting for a message. + If no message is available immediately, then this function blocks until + a message is ready. When the connection is closed, this message - :return: str or bytes - :raises ConnectionClosed: if connection is closed + :rtype: str or bytes + :raises ConnectionClosed: if connection is closed before a message + arrives. ''' if self._close_reason: raise ConnectionClosed(self._close_reason) @@ -547,24 +548,28 @@ async def get_message(self): async def ping(self, payload=None): ''' - Send WebSocket ping to peer and wait for a correspoding pong. + Send WebSocket ping to remote endpoint and wait for a correspoding pong. - Each ping is matched to its expected pong by its payload value. An - exception is raised if you call ping with a ``payload`` value equal to - an existing in-flight ping. 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 any earlier pings_. + 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. - :param payload: The payload to send. If ``None`` then a random value is - created. - :type payload: str, bytes, or None - :raises ConnectionClosed: if connection is closed + *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 Exception('Payload value {} is already in flight.'. + raise ValueError('Payload value {} is already in flight.'. format(payload)) if payload is None: payload = struct.pack('!I', random.getrandbits(32)) @@ -578,7 +583,9 @@ async def pong(self, payload=None): ''' Send an unsolicted pong. - :param payload: str or bytes payloads + :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: @@ -590,10 +597,9 @@ async def send_message(self, message): ''' Send a WebSocket message. - Raises ``ConnectionClosed`` if the connection is closed.. - - :param message: str or bytes - :raises ConnectionClosed: if connection is closed + :param message: The message to send. + :type message: str or bytes + :raises ConnectionClosed: if connection is already closed. ''' if self._close_reason: raise ConnectionClosed(self._close_reason)