├── MANIFEST.in ├── jsonrpc_websocket ├── __init__.py └── jsonrpc.py ├── .coveragerc ├── requirements-test.txt ├── .mailmap ├── tox.ini ├── .gitignore ├── setup.py ├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── README.rst └── tests.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.rst 2 | -------------------------------------------------------------------------------- /jsonrpc_websocket/__init__.py: -------------------------------------------------------------------------------- 1 | from .jsonrpc import Server, TransportError # noqa: F401, F403 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | jsonrpc_websocket 4 | relative_files = True 5 | 6 | [report] 7 | show_missing = True 8 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.7.8 2 | coverage>=5.5 3 | coveralls>=3.0.1 4 | jsonrpc-base>=2.1.0 5 | aiohttp>=3.0.0 6 | pytest-aiohttp>=0.3.0 7 | pytest>=6.2.2 8 | pytest-cov>=2.11.1 9 | pytest-asyncio>=0.14.0 10 | tox>=3.23.0 11 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | # Git .mailmap file. This file changes the display of names in git log, and 2 | # other locations. This lets us change how old names and emails are displayed 3 | # without rewriting git history. 4 | # 5 | # See https://blog.developer.atlassian.com/aliasing-authors-in-git/ 6 | 7 | Emily Love Watson 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py39, 4 | py310, 5 | py311, 6 | py312, 7 | py313, 8 | flake8, 9 | 10 | [testenv] 11 | setenv = 12 | PYTHONPATH = {toxinidir}:{toxinidir}/jsonrpc_websocket 13 | commands = 14 | pytest --cov-report term-missing --cov=jsonrpc_websocket tests.py {posargs} 15 | deps = 16 | -r{toxinidir}/requirements-test.txt 17 | 18 | [testenv:py39] 19 | basepython = python3.9 20 | deps = 21 | {[testenv]deps} 22 | 23 | [testenv:py310] 24 | basepython = python3.10 25 | deps = 26 | {[testenv]deps} 27 | 28 | [testenv:py311] 29 | basepython = python3.11 30 | deps = 31 | {[testenv]deps} 32 | 33 | [testenv:py312] 34 | basepython = python3.12 35 | deps = 36 | {[testenv]deps} 37 | 38 | [testenv:py313] 39 | basepython = python3.13 40 | deps = 41 | {[testenv]deps} 42 | 43 | [testenv:flake8] 44 | basepython = python 45 | commands = flake8 jsonrpc_websocket tests.py 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .mypy_cache 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # Swap files 63 | *.swp 64 | 65 | # Core dumps 66 | core.* 67 | 68 | # Frame dumps 69 | frame* 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | try: 4 | from setuptools import setup 5 | except ImportError: 6 | import sys 7 | print("Please install the `setuptools` package in order to install this library", file=sys.stderr) 8 | raise 9 | 10 | setup( 11 | name='jsonrpc-websocket', 12 | version='3.1.6', 13 | author='Emily Love Watson', 14 | author_email='emily@emlove.me', 15 | packages=('jsonrpc_websocket',), 16 | license='BSD', 17 | keywords='json-rpc async asyncio websocket', 18 | url='http://github.com/emlove/jsonrpc-websocket', 19 | description='''A JSON-RPC websocket client library for asyncio''', 20 | long_description_content_type='text/x-rst', 21 | long_description=open('README.rst').read(), 22 | install_requires=[ 23 | 'jsonrpc-base>=2.1.0', 24 | 'aiohttp>=3.0.0', 25 | 'async-timeout>=4.0.0', 26 | ], 27 | classifiers=[ 28 | 'Development Status :: 5 - Production/Stable', 29 | 'Intended Audience :: Developers', 30 | 'Topic :: Software Development :: Libraries', 31 | 'License :: OSI Approved :: BSD License', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.9', 34 | 'Programming Language :: Python :: 3.10', 35 | 'Programming Language :: Python :: 3.11', 36 | 'Programming Language :: Python :: 3.12', 37 | 'Programming Language :: Python :: 3.13', 38 | ], 39 | 40 | ) 41 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements-test.txt 24 | - name: Lint with flake8 25 | run: | 26 | # stop the build if there are Python syntax errors or undefined names 27 | flake8 jsonrpc_websocket tests.py --count --select=E9,F63,F7,F82 --show-source --statistics 28 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 29 | flake8 jsonrpc_websocket tests.py 30 | - name: Test with pytest 31 | run: | 32 | pytest --cov-report term-missing --cov=jsonrpc_websocket tests.py 33 | - name: Coveralls 34 | uses: AndreMiras/coveralls-python-action@develop 35 | with: 36 | parallel: true 37 | 38 | coveralls_finish: 39 | needs: test 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Coveralls Finished 43 | uses: AndreMiras/coveralls-python-action@develop 44 | with: 45 | parallel-finished: true 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Giuseppe Ciotta 2 | Copyright (c) 2020 Emily Love Watson 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions 7 | are met: 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 3. The name of the author may not be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 17 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 19 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 21 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 23 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 25 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /jsonrpc_websocket/jsonrpc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import sys 4 | 5 | import aiohttp 6 | from aiohttp import ClientError 7 | from aiohttp.http_exceptions import HttpProcessingError 8 | import jsonrpc_base 9 | from jsonrpc_base import TransportError 10 | 11 | if sys.version_info >= (3, 11): 12 | from asyncio import timeout as async_timeout 13 | else: 14 | from async_timeout import timeout as async_timeout 15 | 16 | 17 | class Server(jsonrpc_base.Server): 18 | """A connection to a HTTP JSON-RPC server, backed by aiohttp""" 19 | 20 | def __init__(self, url, session=None, **connect_kwargs): 21 | super().__init__() 22 | self._session = session or aiohttp.ClientSession() 23 | 24 | # True if we made our own session 25 | self._internal_session = session is None 26 | 27 | self._client = None 28 | self._connect_kwargs = connect_kwargs 29 | self._url = url 30 | self._connect_kwargs['headers'] = self._connect_kwargs.get( 31 | 'headers', {}) 32 | self._connect_kwargs['headers']['Content-Type'] = ( 33 | self._connect_kwargs['headers'].get( 34 | 'Content-Type', 'application/json')) 35 | self._connect_kwargs['headers']['Accept'] = ( 36 | self._connect_kwargs['headers'].get( 37 | 'Accept', 'application/json-rpc')) 38 | self._timeout = self._connect_kwargs.get('timeout') 39 | self._pending_messages = {} 40 | 41 | async def send_message(self, message): 42 | """Send the HTTP message to the server and return the message response. 43 | 44 | No result is returned if message is a notification. 45 | """ 46 | if self._client is None: 47 | raise TransportError('Client is not connected.', message) 48 | 49 | try: 50 | await self._client.send_str(message.serialize()) 51 | if message.response_id: 52 | pending_message = PendingMessage() 53 | self._pending_messages[message.response_id] = pending_message 54 | response = await pending_message.wait(self._timeout) 55 | del self._pending_messages[message.response_id] 56 | else: 57 | response = None 58 | return message.parse_response(response) 59 | except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: 60 | raise TransportError('Transport Error', message, exc) 61 | 62 | async def ws_connect(self): 63 | """Connect to the websocket server.""" 64 | if self.connected: 65 | raise TransportError('Connection already open.') 66 | 67 | try: 68 | if self._internal_session and self._session.closed: 69 | self._session = aiohttp.ClientSession() 70 | self._client = await self._session.ws_connect( 71 | self._url, **self._connect_kwargs) 72 | except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: 73 | raise TransportError('Error connecting to server', None, exc) 74 | return self._session.loop.create_task(self._ws_loop()) 75 | 76 | async def _ws_loop(self): 77 | """Listen for messages from the websocket server.""" 78 | msg = None 79 | try: 80 | async for msg in self._client: 81 | if msg.type == aiohttp.WSMsgType.ERROR: 82 | break 83 | elif msg.type == aiohttp.WSMsgType.BINARY: 84 | try: 85 | # If we get a binary message, try and decode it as a 86 | # UTF-8 JSON string, in case the server is sending 87 | # binary websocket messages. If it doens't decode we'll 88 | # ignore it since we weren't expecting binary messages 89 | # anyway 90 | data = json.loads(msg.data.decode()) 91 | except ValueError: 92 | continue 93 | elif msg.type == aiohttp.WSMsgType.TEXT: 94 | try: 95 | data = msg.json() 96 | except ValueError as exc: 97 | raise TransportError('Error Parsing JSON', None, exc) 98 | else: 99 | # This is tested with test_message_ping_ignored, but 100 | # cpython's optimizations prevent coveragepy from detecting 101 | # that it's run 102 | # https://bitbucket.org/ned/coveragepy/issues/198/continue-marked-as-not-covered 103 | continue # pragma: no cover 104 | 105 | if 'method' in data: 106 | request = jsonrpc_base.Request.parse(data) 107 | response = await self.async_receive_request(request) 108 | if response: 109 | await self.send_message(response) 110 | else: 111 | self._pending_messages[data['id']].response = data 112 | 113 | except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: 114 | raise TransportError('Transport Error', None, exc) 115 | finally: 116 | await self.close() 117 | if msg and msg.type == aiohttp.WSMsgType.ERROR: 118 | raise TransportError( 119 | 'Websocket error detected. Connection closed.') 120 | 121 | async def close(self): 122 | """Close the connection to the websocket server.""" 123 | if self.connected: 124 | await self._client.close() 125 | self._client = None 126 | if self._internal_session: 127 | await self._session.close() 128 | 129 | @property 130 | def connected(self): 131 | """Websocket server is connected.""" 132 | return self._client is not None 133 | 134 | 135 | class PendingMessage(object): 136 | """Wait for response of pending message.""" 137 | 138 | def __init__(self): 139 | self._event = asyncio.Event() 140 | self._response = None 141 | 142 | async def wait(self, timeout=None): 143 | async with async_timeout(timeout): 144 | await self._event.wait() 145 | return self._response 146 | 147 | @property 148 | def response(self): 149 | return self._response 150 | 151 | @response.setter 152 | def response(self, value): 153 | self._response = value 154 | self._event.set() 155 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | jsonrpc-websocket: a compact JSON-RPC websocket client library for asyncio 2 | ======================================================================================================= 3 | 4 | .. image:: https://img.shields.io/pypi/v/jsonrpc-websocket.svg 5 | :target: https://pypi.python.org/pypi/jsonrpc-websocket 6 | .. image:: https://github.com/emlove/jsonrpc-websocket/workflows/tests/badge.svg 7 | :target: https://github.com/emlove/jsonrpc-websocket/actions 8 | .. image:: https://coveralls.io/repos/emlove/jsonrpc-websocket/badge.svg?branch=main 9 | :target: https://coveralls.io/github/emlove/jsonrpc-websocket?branch=main 10 | 11 | This is a compact and simple JSON-RPC websocket client implementation for asyncio python code. This code is forked from https://github.com/gciotta/jsonrpc-requests 12 | 13 | Main Features 14 | ------------- 15 | 16 | * Supports nested namespaces (eg. `app.users.getUsers()`) 17 | * 100% test coverage 18 | 19 | Usage 20 | ----- 21 | It is recommended to manage the aiohttp ClientSession object externally and pass it to the Server constructor. `(See the aiohttp documentation.) `_ If not passed to Server, a ClientSession object will be created automatically, and will be closed when the websocket connection is closed. If you pass in an external ClientSession, it is your responsibility to close it when you are finished. 22 | 23 | Execute remote JSON-RPC functions 24 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 25 | 26 | .. code-block:: python 27 | 28 | import asyncio 29 | from jsonrpc_websocket import Server 30 | 31 | async def routine(): 32 | server = Server('ws://localhost:9090') 33 | try: 34 | await server.ws_connect() 35 | 36 | await server.foo(1, 2) 37 | await server.foo(bar=1, baz=2) 38 | await server.foo({'foo': 'bar'}) 39 | await server.foo.bar(baz=1, qux=2) 40 | finally: 41 | await server.close() 42 | 43 | asyncio.get_event_loop().run_until_complete(routine()) 44 | 45 | A notification 46 | ~~~~~~~~~~~~~~ 47 | 48 | .. code-block:: python 49 | 50 | import asyncio 51 | from jsonrpc_websocket import Server 52 | 53 | async def routine(): 54 | server = Server('ws://localhost:9090') 55 | try: 56 | await server.ws_connect() 57 | 58 | await server.foo(bar=1, _notification=True) 59 | finally: 60 | await server.close() 61 | 62 | asyncio.get_event_loop().run_until_complete(routine()) 63 | 64 | Handle requests from server to client 65 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 66 | 67 | .. code-block:: python 68 | 69 | import asyncio 70 | from jsonrpc_websocket import Server 71 | 72 | def client_method(arg1, arg2): 73 | return arg1 + arg2 74 | 75 | async def routine(): 76 | server = Server('ws://localhost:9090') 77 | # client_method is called when server requests method 'namespace.client_method' 78 | server.namespace.client_method = client_method 79 | try: 80 | await server.ws_connect() 81 | finally: 82 | await server.close() 83 | 84 | asyncio.get_event_loop().run_until_complete(routine()) 85 | 86 | Pass through arguments to aiohttp (see also `aiohttp documentation `_) 87 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 88 | 89 | .. code-block:: python 90 | 91 | import asyncio 92 | import aiohttp 93 | from jsonrpc_websocket import Server 94 | 95 | async def routine(): 96 | server = Server( 97 | 'ws://localhost:9090', 98 | auth=aiohttp.BasicAuth('user', 'pass'), 99 | headers={'x-test2': 'true'}) 100 | try: 101 | await server.ws_connect() 102 | 103 | await server.foo() 104 | finally: 105 | await server.close() 106 | 107 | asyncio.get_event_loop().run_until_complete(routine()) 108 | 109 | Pass through aiohttp exceptions 110 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 111 | 112 | .. code-block:: python 113 | 114 | import asyncio 115 | import aiohttp 116 | from jsonrpc_websocket import Server 117 | 118 | async def routine(): 119 | server = Server('ws://unknown-host') 120 | try: 121 | await server.ws_connect() 122 | 123 | await server.foo() 124 | except TransportError as transport_error: 125 | print(transport_error.args[1]) # this will hold a aiohttp exception instance 126 | finally: 127 | await server.close() 128 | 129 | asyncio.get_event_loop().run_until_complete(routine()) 130 | 131 | Tests 132 | ----- 133 | Install the Python tox package and run ``tox``, it'll test this package with various versions of Python. 134 | 135 | Changelog 136 | --------- 137 | 3.1.6 (2025-09-18) 138 | ~~~~~~~~~~~~~~~~~~ 139 | - Port to pytest-asyncio >= 1.0.0 `(#16) `_ `@cjwatson `_ 140 | - Use built-in asyncio timeout for python versions 11 and up 141 | 142 | 3.1.5 (2024-01-16) 143 | ~~~~~~~~~~~~~~~~~~ 144 | - Add explicit dependency to async-timeout `(#13) `_ `@miettal `_ 145 | 146 | 3.1.4 (2022-05-23) 147 | ~~~~~~~~~~~~~~~~~~ 148 | - Only reconnect session when the session is managed internally 149 | - Remove deprecated with timeout syntax 150 | 151 | 3.1.3 (2022-05-23) 152 | ~~~~~~~~~~~~~~~~~~ 153 | - Fix unclosed client session bug `(#12) `_ `@Arjentix `_ 154 | 155 | 3.1.2 (2022-05-03) 156 | ~~~~~~~~~~~~~~~~~~ 157 | - Unpin test dependencies 158 | 159 | 3.1.1 (2021-11-21) 160 | ~~~~~~~~~~~~~~~~~~ 161 | - Fixed compatibility with async_timeout 4.0 162 | 163 | 3.1.0 (2021-05-03) 164 | ~~~~~~~~~~~~~~~~~~ 165 | - Bumped jsonrpc-base to version 2.1.0 166 | 167 | 3.0.0 (2021-03-17) 168 | ~~~~~~~~~~~~~~~~~~ 169 | - Bumped jsonrpc-base to version 2.0.0 170 | - BREAKING CHANGE: `Allow single mapping as a positional parameter. `_ 171 | Previously, when calling with a single dict as a parameter (example: ``server.foo({'bar': 0})``), the mapping was used as the JSON-RPC keyword parameters. This made it impossible to send a mapping as the first and only positional parameter. If you depended on the old behavior, you can recreate it by spreading the mapping as your method's kwargs. (example: ``server.foo(**{'bar': 0})``) 172 | 173 | 2.0.0 (2020-12-22) 174 | ~~~~~~~~~~~~~~~~~~ 175 | - Remove session as a reserved attribute on Server 176 | 177 | 1.2.1 (2020-09-11) 178 | ~~~~~~~~~~~~~~~~~~ 179 | - Fix loop not closing after client closes 180 | 181 | 1.2.0 (2020-08-24) 182 | ~~~~~~~~~~~~~~~~~~ 183 | - Support for async server request handlers 184 | 185 | 1.1.0 (2020-02-17) 186 | ~~~~~~~~~~~~~~~~~~ 187 | - Support servers that send JSON-RPC requests as binary messages encoded with UTF-8 `(#5) `_ `@shiaky `_ 188 | 189 | 1.0.2 (2019-11-12) 190 | ~~~~~~~~~~~~~~~~~~ 191 | - Bumped jsonrpc-base to version 1.0.3 192 | 193 | 1.0.1 (2018-08-23) 194 | ~~~~~~~~~~~~~~~~~~ 195 | - Bumped jsonrpc-base to version 1.0.2 196 | 197 | 1.0.0 (2018-07-06) 198 | ~~~~~~~~~~~~~~~~~~ 199 | - Bumped jsonrpc-base to version 1.0.1 200 | 201 | 0.6 (2018-03-11) 202 | ~~~~~~~~~~~~~~~~ 203 | - Minimum required version of aiohttp is now 3.0. 204 | - Support for Python 3.4 is now dropped. 205 | 206 | Credits 207 | ------- 208 | `@gciotta `_ for creating the base project `jsonrpc-requests `_. 209 | 210 | `@mbroadst `_ for providing full support for nested method calls, JSON-RPC RFC 211 | compliance and other improvements. 212 | 213 | `@vaab `_ for providing api and tests improvements, better RFC compliance. 214 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from unittest import mock 4 | import sys 5 | 6 | import aiohttp 7 | from aiohttp import ClientWebSocketResponse 8 | import aiohttp.web 9 | import pytest 10 | import pytest_asyncio 11 | 12 | import jsonrpc_base 13 | import jsonrpc_websocket.jsonrpc 14 | from jsonrpc_websocket import Server, TransportError 15 | 16 | if sys.version_info[:2] < (3, 8): 17 | from asynctest import patch 18 | else: 19 | from unittest.mock import patch 20 | 21 | pytestmark = pytest.mark.asyncio 22 | 23 | 24 | class JsonTestClient(): 25 | def __init__(self, loop=None): 26 | self.test_server = None 27 | self.loop = loop 28 | self.connect_side_effect = None 29 | 30 | async def ws_connect(self, *args, **kwargs): 31 | if self.connect_side_effect: 32 | self.connect_side_effect() 33 | self.test_server = JsonTestServer(self.loop) 34 | return self.test_server 35 | 36 | async def close(self): 37 | self._test_server = None 38 | 39 | @property 40 | def handler(self): 41 | return self.test_server.send_handler 42 | 43 | @handler.setter 44 | def handler(self, value): 45 | self.test_server.send_handler = value 46 | 47 | def receive(self, data): 48 | self.test_server.test_receive(data) 49 | 50 | def receive_binary(self, data): 51 | self.test_server.test_binary(data) 52 | 53 | @property 54 | def closed(self): 55 | return self.test_server is None 56 | 57 | 58 | class JsonTestServer(ClientWebSocketResponse): 59 | def __init__(self, loop=None): 60 | self.loop = loop 61 | self.send_handler = None 62 | self.receive_queue = asyncio.Queue() 63 | self._closed = False 64 | self.receive_side_effect = None 65 | 66 | async def send_str(self, data): 67 | self.send_handler(self, data) 68 | 69 | def test_receive(self, data): 70 | self.receive_queue.put_nowait( 71 | aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, data, '')) 72 | 73 | def test_binary(self, data=bytes()): 74 | self.receive_queue.put_nowait( 75 | aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, data, '')) 76 | 77 | def test_error(self): 78 | self.receive_queue.put_nowait( 79 | aiohttp.WSMessage(aiohttp.WSMsgType.ERROR, 0, '')) 80 | 81 | def test_close(self): 82 | self.receive_queue.put_nowait( 83 | aiohttp.WSMessage(aiohttp.WSMsgType.CLOSED, None, None)) 84 | 85 | def test_ping(self): 86 | self.receive_queue.put_nowait( 87 | aiohttp.WSMessage(aiohttp.WSMsgType.PING, 0, '')) 88 | 89 | async def receive(self): 90 | value = await self.receive_queue.get() 91 | if self.receive_side_effect: 92 | self.receive_side_effect() 93 | return (value) 94 | 95 | async def close(self): 96 | if not self._closed: 97 | self._closed = True 98 | self.receive_queue.put_nowait( 99 | aiohttp.WSMessage(aiohttp.WSMsgType.CLOSING, None, None)) 100 | 101 | 102 | def assertSameJSON(json1, json2): 103 | """Tells whether two json strings, once decoded, are the same dictionary""" 104 | assert json.loads(json1) == json.loads(json2) 105 | 106 | 107 | @pytest_asyncio.fixture 108 | async def client(): 109 | """Generate a mock json server.""" 110 | return JsonTestClient(asyncio.get_running_loop()) 111 | 112 | 113 | @pytest_asyncio.fixture 114 | async def server(client): 115 | """Generate a mock json server.""" 116 | server = Server('/xmlrpc', session=client, timeout=0.2) 117 | client.run_loop_future = await server.ws_connect() 118 | yield server 119 | if server.connected: 120 | client.test_server.test_close() 121 | await client.run_loop_future 122 | 123 | 124 | def test_pending_message_response(): 125 | pending_message = jsonrpc_websocket.jsonrpc.PendingMessage() 126 | pending_message.response = 10 127 | assert pending_message.response == 10 128 | 129 | 130 | async def test_internal_session(client): 131 | with patch('jsonrpc_websocket.jsonrpc.aiohttp.ClientSession', 132 | return_value=client) as client_class: 133 | server = Server('/xmlrpc', timeout=0.2) 134 | client_class.assert_called_once() 135 | 136 | await server.close() 137 | 138 | await server.ws_connect() 139 | 140 | assert client_class.call_count == 2 141 | 142 | 143 | async def test_send_message(server): 144 | # catch timeout responses 145 | with pytest.raises(TransportError) as transport_error: 146 | def handler(server, data): 147 | try: 148 | sleep_coroutine = asyncio.sleep(10) 149 | wait_coroutine = asyncio.wait(sleep_coroutine) 150 | except asyncio.CancelledError: 151 | # event loop will be terminated before sleep finishes 152 | pass 153 | 154 | # Prevent warning about non-awaited coroutines 155 | sleep_coroutine.close() 156 | wait_coroutine.close() 157 | 158 | server._session.handler = handler 159 | await server.send_message( 160 | jsonrpc_base.Request('my_method', params=None, msg_id=1)) 161 | 162 | assert isinstance(transport_error.value.args[1], asyncio.TimeoutError) 163 | 164 | 165 | async def test_client_closed(server): 166 | assert server._session.run_loop_future.done() is False 167 | await server.close() 168 | assert server._session.run_loop_future.done() is False 169 | await server._session.run_loop_future 170 | assert server._session.run_loop_future.done() is True 171 | with pytest.raises(TransportError, match='Client is not connected.'): 172 | def handler(server, data): 173 | pass 174 | server._session.handler = handler 175 | await server.send_message( 176 | jsonrpc_base.Request('my_method', params=None, msg_id=1)) 177 | 178 | 179 | async def test_double_connect(server): 180 | with pytest.raises(TransportError, match='Connection already open.'): 181 | await server.ws_connect() 182 | 183 | 184 | async def test_ws_error(server): 185 | server._session.test_server.test_error() 186 | with pytest.raises( 187 | TransportError, 188 | match='Websocket error detected. Connection closed.'): 189 | await server._session.run_loop_future 190 | 191 | 192 | async def test_binary(server): 193 | server._session.test_server.test_binary() 194 | 195 | 196 | async def test_message_not_json(server): 197 | with pytest.raises(TransportError) as transport_error: 198 | server._session.receive('not json') 199 | await server._session.run_loop_future 200 | assert isinstance(transport_error.value.args[1], ValueError) 201 | 202 | 203 | async def test_message_binary_not_utf8(server): 204 | # If we get a binary message, we should try to decode it as JSON, but 205 | # if it's not valid we should just ignore it, and an exception should 206 | # not be thrown 207 | server._session.receive_binary(bytes((0xE0, 0x80, 0x80))) 208 | server._session.test_server.test_close() 209 | await server._session.run_loop_future 210 | 211 | 212 | async def test_message_binary_not_json(server): 213 | # If we get a binary message, we should try to decode it as JSON, but 214 | # if it's not valid we should just ignore it, and an exception should 215 | # not be thrown 216 | server._session.receive_binary('not json'.encode()) 217 | server._session.test_server.test_close() 218 | await server._session.run_loop_future 219 | 220 | 221 | async def test_message_ping_ignored(server): 222 | server._session.test_server.test_ping() 223 | server._session.test_server.test_close() 224 | await server._session.run_loop_future 225 | 226 | 227 | async def test_connection_timeout(server): 228 | def bad_connect(): 229 | raise aiohttp.ClientError("Test Error") 230 | server._session.connect_side_effect = bad_connect 231 | await server.close() 232 | with pytest.raises(TransportError) as transport_error: 233 | await server.ws_connect() 234 | assert isinstance(transport_error.value.args[1], aiohttp.ClientError) 235 | 236 | 237 | async def test_server_request(server): 238 | def test_method(): 239 | return 1 240 | server.test_method = test_method 241 | 242 | def handler(server, data): 243 | response = json.loads(data) 244 | assert response["result"] == 1 245 | 246 | server._session.handler = handler 247 | 248 | server._session.receive( 249 | '{"jsonrpc": "2.0", "method": "test_method", "id": 1}') 250 | server._session.test_server.test_close() 251 | await server._session.run_loop_future 252 | 253 | 254 | async def test_server_async_request(server): 255 | async def test_method_async(): 256 | return 2 257 | server.test_method_async = test_method_async 258 | 259 | def handler(server, data): 260 | response = json.loads(data) 261 | assert response["result"] == 2 262 | server._session.handler = handler 263 | 264 | server._session.receive( 265 | '{"jsonrpc": "2.0", "method": "test_method_async", "id": 1}') 266 | server._session.test_server.test_close() 267 | await server._session.run_loop_future 268 | 269 | 270 | async def test_server_request_binary(server): 271 | # Test that if the server sends a binary websocket message, that's a 272 | # UTF-8 encoded JSON request we process it 273 | def test_method_binary(): 274 | return 1 275 | server.test_method_binary = test_method_binary 276 | 277 | def handler(server, data): 278 | response = json.loads(data) 279 | assert response["result"] == 1 280 | 281 | server._session.handler = handler 282 | 283 | server._session.receive_binary( 284 | '{"jsonrpc": "2.0", "method": "test_method_binary", "id": 1}'.encode()) 285 | server._session.test_server.test_close() 286 | await server._session.run_loop_future 287 | 288 | 289 | async def test_server_notification(server): 290 | def test_notification(): 291 | pass 292 | server.test_notification = test_notification 293 | server._session.receive( 294 | '{"jsonrpc": "2.0", "method": "test_notification"}') 295 | server._session.test_server.test_close() 296 | await server._session.run_loop_future 297 | 298 | 299 | async def test_server_response_error(server): 300 | def test_error(): 301 | return 1 302 | server.test_error = test_error 303 | 304 | def receive_side_effect(): 305 | raise aiohttp.ClientError("Test Error") 306 | server._session.test_server.receive_side_effect = receive_side_effect 307 | server._session.receive( 308 | '{"jsonrpc": "2.0", "method": "test_error", "id": 1}') 309 | server._session.test_server.test_close() 310 | 311 | with pytest.raises(TransportError) as transport_error: 312 | await server._session.run_loop_future 313 | assert isinstance(transport_error.value.args[1], aiohttp.ClientError) 314 | 315 | 316 | async def test_calls(server): 317 | # rpc call with positional parameters: 318 | def handler1(server, data): 319 | request = json.loads(data) 320 | assert request["params"] == [42, 23] 321 | server.test_receive( 322 | '{"jsonrpc": "2.0", "result": 19, "id": "abcd-1234"}') 323 | 324 | server._session.handler = handler1 325 | with mock.patch("uuid.uuid4", return_value="abcd-1234"): 326 | assert (await server.subtract(42, 23)) == 19 327 | 328 | def handler2(server, data): 329 | request = json.loads(data) 330 | assert request["params"] == {'y': 23, 'x': 42} 331 | server.test_receive( 332 | '{"jsonrpc": "2.0", "result": 19, "id": "abcd-1234"}') 333 | 334 | server._session.handler = handler2 335 | with mock.patch("uuid.uuid4", return_value="abcd-1234"): 336 | assert (await server.subtract(x=42, y=23)) == 19 337 | 338 | def handler3(server, data): 339 | request = json.loads(data) 340 | assert request["params"] == [{'foo': 'bar'}] 341 | 342 | server._session.handler = handler3 343 | await server.foobar({'foo': 'bar'}, _notification=True) 344 | 345 | def handler3(server, data): 346 | request = json.loads(data) 347 | assert request["params"] == {'foo': 'bar'} 348 | 349 | server._session.handler = handler3 350 | await server.foobar(**{'foo': 'bar'}, _notification=True) 351 | 352 | 353 | async def test_simultaneous_calls(server): 354 | # Test that calls can be delivered simultaneously, and can return out 355 | # of order 356 | event_loop = asyncio.get_running_loop() 357 | 358 | def handler(server, data): 359 | pass 360 | 361 | server._session.handler = handler 362 | 363 | with mock.patch("uuid.uuid4", return_value="abcd-1234"): 364 | task1 = event_loop.create_task(server.call1()) 365 | with mock.patch("uuid.uuid4", return_value="efgh-5678"): 366 | task2 = event_loop.create_task(server.call2()) 367 | 368 | assert task1.done() is False 369 | assert task2.done() is False 370 | 371 | server._session.receive( 372 | '{"jsonrpc": "2.0", "result": 2, "id": "efgh-5678"}') 373 | await task2 374 | 375 | assert task1.done() is False 376 | assert task2.done() 377 | 378 | server._session.receive( 379 | '{"jsonrpc": "2.0", "result": 1, "id": "abcd-1234"}') 380 | await task1 381 | 382 | assert task1.done() 383 | assert task2.done() 384 | 385 | assert 1 == task1.result() 386 | assert 2 == task2.result() 387 | 388 | 389 | async def test_notification(server): 390 | # Verify that we ignore the server response 391 | def handler(server, data): 392 | pass 393 | server._session.handler = handler 394 | assert (await server.subtract(42, 23, _notification=True)) is None 395 | --------------------------------------------------------------------------------