From 72af1649ec02fabfc78dca72f6e881e9c188fe53 Mon Sep 17 00:00:00 2001 From: "Mark E. Haase" Date: Wed, 24 Oct 2018 16:36:20 -0400 Subject: [PATCH 1/4] Creating initial documentation (#57) I've moved some content from README.md to the docs directory and also greatly expanded the content. I also noticed that I was unwisely using an Exception where a ValueError would be better, so I changed that and its corresponding unit test. After pushing this, I will set up Read The Docs. --- README.md | 232 +++++-------------------------------- docs/.gitignore | 2 + docs/Makefile | 19 +++ docs/_static/README.txt | 3 + docs/api.rst | 82 +++++++++++++ docs/clients.rst | 66 +++++++++++ docs/conf.py | 181 +++++++++++++++++++++++++++++ docs/contributing.rst | 181 +++++++++++++++++++++++++++++ docs/getting_started.rst | 96 +++++++++++++++ docs/index.rst | 37 ++++++ docs/make.bat | 35 ++++++ docs/recipes.rst | 61 ++++++++++ docs/servers.rst | 57 +++++++++ setup.py | 2 + tests/test_connection.py | 2 +- trio_websocket/__init__.py | 133 +++++++++++---------- 16 files changed, 925 insertions(+), 264 deletions(-) create mode 100644 docs/.gitignore create mode 100644 docs/Makefile create mode 100644 docs/_static/README.txt create mode 100644 docs/api.rst create mode 100644 docs/clients.rst create mode 100644 docs/conf.py create mode 100644 docs/contributing.rst create mode 100644 docs/getting_started.rst create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/recipes.rst create mode 100644 docs/servers.rst diff --git a/README.md b/README.md index 7c42ac8..699c97d 100644 --- a/README.md +++ b/README.md @@ -3,32 +3,32 @@ ![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) +![Read the Docs](https://img.shields.io/readthedocs/trio-websocket.svg) # 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/). +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). ## 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..ebed75b --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,82 @@ +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 only advisable for esoteric use cases. + + A connection has properties that expose the connection's role (client vs. + server) and other 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 + :members: diff --git a/docs/clients.rst b/docs/clients.rst new file mode 100644 index 0000000..f1a4730 --- /dev/null +++ b/docs/clients.rst @@ -0,0 +1,66 @@ +.. _websocket-clients: + +Clients +======= + +.. currentmodule:: trio_websocket + +Context Managers +---------------- + +This page goes into the details of creating a WebSocket client. Let's start by +revisiting the example from :ref:`client-tutorial`. + +.. 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/port/path and one to connect by URL. + +.. autofunction:: open_websocket +.. autofunction:: open_websocket_url + +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..5072500 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,181 @@ +# -*- 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', +] + +# 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/getting_started.rst b/docs/getting_started.rst new file mode 100644 index 0000000..9c99156 --- /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, look at the +documentation for :ref:`developer-installation`. + +.. _client-tutorial: + +Client Tutorial +--------------- + +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 message. The context manager also closes the connection +before exiting the block. + +For more details and examples, see :ref:`websocket-clients`. + +.. _server-tutorial: + +Server Tutorial +--------------- + +This example briefly demonstrates how to create a WebSocket server. This server +is an *echo server*, i.e. it reads a message and then sends back the same +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..2841f3a --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,37 @@ +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 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..2a7fa07 --- /dev/null +++ b/docs/servers.rst @@ -0,0 +1,57 @@ +.. _websocket-servers: + +Servers +======= + +.. currentmodule:: trio_websocket + +Creating A Server +----------------- + +This page goes into the details of creating a WebSocket server. Let's start by +revisiting the example from :ref:`server-tutorial`. + +.. 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 and port to bind to. The handler function receives a +:class:`WebSocketRequest` object, and it calls ``accept()`` to finish the +handshake and obtain a +:class:`WebSocketConnection` object. + +.. autofunction:: serve_websocket + +Serving Arbitrary 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..c6c0e6b 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,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..af2efdc 100644 --- a/trio_websocket/__init__.py +++ b/trio_websocket/__init__.py @@ -36,10 +36,10 @@ async def open_websocket(host, port, resource, *, use_ssl, subprotocols=None): 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 + :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. ''' @@ -60,14 +60,14 @@ async def connect_websocket(nursery, host, port, resource, *, use_ssl, 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 + :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() @@ -109,8 +109,9 @@ def open_websocket_url(url, ssl_context=None, *, subprotocols=None): 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. + :type ssl_context: ssl.SSLContext or None :param subprotocols: An iterable of strings representing preferred subprotocols. ''' @@ -132,12 +133,13 @@ async def connect_websocket_url(nursery, url, ssl_context=None, *, 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 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 +154,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'): @@ -172,10 +174,11 @@ async def wrap_client_stream(nursery, stream, host, resource, *, Wrap an arbitrary stream in a client-side ``WebSocketConnection``. This is a low-level function only needed in rare cases. Most users should - call ``open_websocket()`` or ``open_websocket_url()``. + call :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,16 +198,16 @@ 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``. + The object returned is a :class:`WebSocketRequest`, which indicates the + client's proposed handshake. Call ``accept()`` on this object to obtain a + :class:`WebSocketConnection`. This is a low-level function only needed in rare cases. Most users should - call ``serve_websocket()`. + call :func: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 + :param nursery: A nursery to run background tasks in. + :param stream: A stream to be wrapped. + :type stream: trio.abc.Stream :rtype: WebSocketRequest ''' wsproto = wsconnection.WSConnection(wsconnection.SERVER) @@ -224,7 +227,7 @@ async def serve_websocket(handler, host, port, ssl_context, *, is accepting connections and then return the 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 @@ -365,16 +368,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 +387,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 +405,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 +470,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. @@ -529,13 +537,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 +556,25 @@ 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 ping must include a unique payload. This function sends the ping + and then waits for a corresponding pong from the remote endpoint. 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*. - :param payload: The payload to send. If ``None`` then a random value is - created. + :param payload: The payload to send. If ``None`` then a random 32-bit + payload is created. :type payload: str, bytes, or None - :raises ConnectionClosed: if connection is closed + :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 +588,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: str, bytes, or None :raises ConnectionClosed: if connection is closed ''' if self._close_reason: @@ -592,8 +604,9 @@ async def send_message(self, 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) From 0aaa314686ad5973175a0ee07959bf81341d4bdb Mon Sep 17 00:00:00 2001 From: "Mark E. Haase" Date: Thu, 25 Oct 2018 11:41:34 -0400 Subject: [PATCH 2/4] Proofread docs and set up RTD (#57) * Published to RTD. * Proofread the previous commit. * Added sphinxcontrib-trio so that async APIs are documented correctly. * Added credits page. --- README.md | 14 ++--- docs/api.rst | 8 +-- docs/clients.rst | 11 ++-- docs/conf.py | 1 + docs/credits.rst | 13 +++++ docs/getting_started.rst | 10 ++-- docs/index.rst | 1 + docs/servers.rst | 12 ++-- setup.py | 1 + trio_websocket/__init__.py | 117 +++++++++++++++++-------------------- 10 files changed, 99 insertions(+), 89 deletions(-) create mode 100644 docs/credits.rst diff --git a/README.md b/README.md index 699c97d..6c9912f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,3 @@ -[![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) -![Read the Docs](https://img.shields.io/readthedocs/trio-websocket.svg) - # Trio WebSocket This library implements [the WebSocket @@ -20,6 +13,13 @@ framework](https://trio.readthedocs.io/en/latest/). This library passes the 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) +[![Read the Docs](https://img.shields.io/readthedocs/trio-websocket.svg)](https://trio-websocket.readthedocs.io) + ## Installation This library requires Python 3.5 or greater. To install from PyPI: diff --git a/docs/api.rst b/docs/api.rst index ebed75b..9e3bef4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -37,10 +37,9 @@ Connections 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 only advisable for esoteric use cases. + tricky and is not recommended. - A connection has properties that expose the connection's role (client vs. - server) and other connection metadata. + This object has properties that expose connection metadata. .. autoattribute:: is_closed .. autoattribute:: close_reason @@ -68,7 +67,7 @@ Connections .. automethod:: pong Finally, the socket offers a method to close the connection. The connection - context managers in ref:`websocket-clients` and :ref:`websocket-servers` + 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. @@ -79,4 +78,3 @@ Connections :members: .. autoexception:: ConnectionClosed - :members: diff --git a/docs/clients.rst b/docs/clients.rst index f1a4730..8daf200 100644 --- a/docs/clients.rst +++ b/docs/clients.rst @@ -5,11 +5,11 @@ Clients .. currentmodule:: trio_websocket -Context Managers ----------------- +Creating A Client +----------------- This page goes into the details of creating a WebSocket client. Let's start by -revisiting the example from :ref:`client-tutorial`. +revisiting the example from the :ref:`client-tutorial`. .. code-block:: python :linenos: @@ -38,10 +38,13 @@ 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/port/path and one to connect by URL. +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 -------------- diff --git a/docs/conf.py b/docs/conf.py index 5072500..a5c4af5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,6 +40,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', + 'sphinxcontrib_trio', ] # Add any paths that contain templates here, relative to this directory. 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 index 9c99156..53bc4b6 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -20,8 +20,8 @@ Change into the project directory and run the following command. $ pip3 install . -If you want to contribute to development of the library, look at the -documentation for :ref:`developer-installation`. +If you want to contribute to development of the library, also see +:ref:`developer-installation`. .. _client-tutorial: @@ -52,7 +52,7 @@ 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 message. The context manager also closes the connection +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`. @@ -63,8 +63,8 @@ Server Tutorial --------------- This example briefly demonstrates how to create a WebSocket server. This server -is an *echo server*, i.e. it reads a message and then sends back the same -message. +is an *echo server*, i.e. it responds to each incoming message by sending back +an identical message. .. code-block:: python :linenos: diff --git a/docs/index.rst b/docs/index.rst index 2841f3a..ae3933c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,3 +35,4 @@ Autobahn Test Suite `__. api recipes contributing + credits diff --git a/docs/servers.rst b/docs/servers.rst index 2a7fa07..45e89c6 100644 --- a/docs/servers.rst +++ b/docs/servers.rst @@ -9,7 +9,7 @@ Creating A Server ----------------- This page goes into the details of creating a WebSocket server. Let's start by -revisiting the example from :ref:`server-tutorial`. +revisiting the example from the :ref:`server-tutorial`. .. code-block:: python :linenos: @@ -37,15 +37,15 @@ revisiting the example from :ref:`server-tutorial`. `__. As explained in the tutorial, a WebSocket server needs a handler function and a -host and port to bind to. The handler function receives a -:class:`WebSocketRequest` object, and it calls ``accept()`` to finish the -handshake and obtain 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 -Serving Arbitrary Stream ------------------------- +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 diff --git a/setup.py b/setup.py index c6c0e6b..d638050 100644 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ 'pytest-trio>=0.5.0', 'sphinx', 'sphinx_rtd_theme', + 'sphinxcontrib-trio', 'trustme', ], }, diff --git a/trio_websocket/__init__.py b/trio_websocket/__init__.py index af2efdc..38977c4 100644 --- a/trio_websocket/__init__.py +++ b/trio_websocket/__init__.py @@ -28,17 +28,16 @@ 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. + 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,12 +52,13 @@ 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. + 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. - 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. + 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. @@ -101,16 +101,13 @@ 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. - - 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. + 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. + :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. @@ -123,15 +120,13 @@ 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 SSL context used for ``wss:`` URLs. @@ -171,10 +166,10 @@ 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 :func:open_websocket() or :func: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. @@ -198,17 +193,13 @@ async def wrap_server_stream(nursery, stream): ''' Wrap an arbitrary stream in a server-side WebSocket. - The object returned is a :class:`WebSocketRequest`, which indicates the - client's proposed handshake. Call ``accept()`` on this object to obtain a - :class:`WebSocketConnection`. - - This is a low-level function only needed in rare cases. Most users should - call :func:serve_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. :type stream: trio.abc.Stream - :rtype: WebSocketRequest + :rtype: WebSocketConnection ''' wsproto = wsconnection.WSConnection(wsconnection.SERVER) connection = WebSocketConnection(stream, wsproto) @@ -224,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!* - :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) @@ -267,7 +256,8 @@ def __init__(self, reason): ''' Constructor. - :param CloseReason reason: + :param reason: + :type reason: CloseReason ''' self.reason = reason @@ -301,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): @@ -518,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 @@ -558,15 +550,18 @@ async def ping(self, payload=None): ''' Send WebSocket ping to remote endpoint and wait for a correspoding pong. - Each ping must include a unique payload. This function sends the ping - and then waits for a corresponding pong from the remote endpoint. 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. + + *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: str, bytes, or None + :type payload: bytes or None :raises ConnectionClosed: if connection is closed. :raises ValueError: if ``payload`` is identical to another in-flight ping. @@ -590,7 +585,7 @@ async def pong(self, payload=None): :param payload: The pong's payload. If ``None``, then no payload is sent. - :type payload: str, bytes, or None + :type payload: bytes or None :raises ConnectionClosed: if connection is closed ''' if self._close_reason: @@ -602,8 +597,6 @@ async def send_message(self, message): ''' Send a WebSocket message. - Raises ``ConnectionClosed`` if the connection is closed.. - :param message: The message to send. :type message: str or bytes :raises ConnectionClosed: if connection is already closed. From 22d785dda8bcc57a318069329cdd4cfc0aea9897 Mon Sep 17 00:00:00 2001 From: "Mark E. Haase" Date: Thu, 25 Oct 2018 11:49:11 -0400 Subject: [PATCH 3/4] Quick fix for build failure (#57) The build fails because it can't find sphinxcontrib_trio, which is is a "dev" dependency. This quick fix is to make it a real dependency. This might be fixable by doing #61? --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d638050..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', @@ -52,7 +53,6 @@ 'pytest-trio>=0.5.0', 'sphinx', 'sphinx_rtd_theme', - 'sphinxcontrib-trio', 'trustme', ], }, From 5fdc3c865ffc51e040d708b31a1cf22e14a17b6f Mon Sep 17 00:00:00 2001 From: "Mark E. Haase" Date: Fri, 26 Oct 2018 11:17:56 -0400 Subject: [PATCH 4/4] Addressing feedback (#57) --- docs/clients.rst | 6 +++--- docs/getting_started.rst | 10 +++++----- docs/servers.rst | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/clients.rst b/docs/clients.rst index 8daf200..94daf29 100644 --- a/docs/clients.rst +++ b/docs/clients.rst @@ -5,11 +5,11 @@ Clients .. currentmodule:: trio_websocket -Creating A Client ------------------ +Client Tutorial +--------------- This page goes into the details of creating a WebSocket client. Let's start by -revisiting the example from the :ref:`client-tutorial`. +revisiting the :ref:`client-example`. .. code-block:: python :linenos: diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 53bc4b6..5fc23c8 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -23,10 +23,10 @@ Change into the project directory and run the following command. If you want to contribute to development of the library, also see :ref:`developer-installation`. -.. _client-tutorial: +.. _client-example: -Client Tutorial ---------------- +Client Example +-------------- This example briefly demonstrates how to create a WebSocket client. @@ -57,9 +57,9 @@ before exiting the block. For more details and examples, see :ref:`websocket-clients`. -.. _server-tutorial: +.. _server-example: -Server Tutorial +Server Example --------------- This example briefly demonstrates how to create a WebSocket server. This server diff --git a/docs/servers.rst b/docs/servers.rst index 45e89c6..b01d078 100644 --- a/docs/servers.rst +++ b/docs/servers.rst @@ -5,11 +5,11 @@ Servers .. currentmodule:: trio_websocket -Creating A Server ------------------ +Server Tutorial +--------------- This page goes into the details of creating a WebSocket server. Let's start by -revisiting the example from the :ref:`server-tutorial`. +revisiting the :ref:`server-example`. .. code-block:: python :linenos: