├── .coveragerc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .mailmap ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── jsonrpc_websocket ├── __init__.py └── jsonrpc.py ├── requirements-test.txt ├── setup.py ├── tests.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | jsonrpc_websocket 4 | relative_files = True 5 | 6 | [report] 7 | show_missing = True 8 | -------------------------------------------------------------------------------- /.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.7, 3.8, 3.9, "3.10"] 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 | if [[ "${{ matrix.python-version }}" == "3.7" ]] ; then pip install asynctest==0.13.0 ; fi 25 | - name: Lint with flake8 26 | run: | 27 | # stop the build if there are Python syntax errors or undefined names 28 | flake8 jsonrpc_websocket tests.py --count --select=E9,F63,F7,F82 --show-source --statistics 29 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 30 | flake8 jsonrpc_websocket tests.py 31 | - name: Test with pytest 32 | run: | 33 | pytest --cov-report term-missing --cov=jsonrpc_websocket tests.py 34 | - name: Coveralls 35 | uses: AndreMiras/coveralls-python-action@develop 36 | with: 37 | parallel: true 38 | 39 | coveralls_finish: 40 | needs: test 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Coveralls Finished 44 | uses: AndreMiras/coveralls-python-action@develop 45 | with: 46 | parallel-finished: true 47 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 Mills 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Giuseppe Ciotta 2 | Copyright (c) 2020 Emily Mills 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.rst 2 | -------------------------------------------------------------------------------- /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.5 (2024-01-16) 138 | ~~~~~~~~~~~~~~~~~~ 139 | - Add explicit dependency to async-timeout `(#13) `_ `@miettal `_ 140 | 141 | 3.1.4 (2022-05-23) 142 | ~~~~~~~~~~~~~~~~~~ 143 | - Only reconnect session when the session is managed internally 144 | - Remove deprecated with timeout syntax 145 | 146 | 3.1.3 (2022-05-23) 147 | ~~~~~~~~~~~~~~~~~~ 148 | - Fix unclosed client session bug `(#12) `_ `@Arjentix `_ 149 | 150 | 3.1.2 (2022-05-03) 151 | ~~~~~~~~~~~~~~~~~~ 152 | - Unpin test dependencies 153 | 154 | 3.1.1 (2021-11-21) 155 | ~~~~~~~~~~~~~~~~~~ 156 | - Fixed compatibility with async_timeout 4.0 157 | 158 | 3.1.0 (2021-05-03) 159 | ~~~~~~~~~~~~~~~~~~ 160 | - Bumped jsonrpc-base to version 2.1.0 161 | 162 | 3.0.0 (2021-03-17) 163 | ~~~~~~~~~~~~~~~~~~ 164 | - Bumped jsonrpc-base to version 2.0.0 165 | - BREAKING CHANGE: `Allow single mapping as a positional parameter. `_ 166 | 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})``) 167 | 168 | 2.0.0 (2020-12-22) 169 | ~~~~~~~~~~~~~~~~~~ 170 | - Remove session as a reserved attribute on Server 171 | 172 | 1.2.1 (2020-09-11) 173 | ~~~~~~~~~~~~~~~~~~ 174 | - Fix loop not closing after client closes 175 | 176 | 1.2.0 (2020-08-24) 177 | ~~~~~~~~~~~~~~~~~~ 178 | - Support for async server request handlers 179 | 180 | 1.1.0 (2020-02-17) 181 | ~~~~~~~~~~~~~~~~~~ 182 | - Support servers that send JSON-RPC requests as binary messages encoded with UTF-8 `(#5) `_ `@shiaky `_ 183 | 184 | 1.0.2 (2019-11-12) 185 | ~~~~~~~~~~~~~~~~~~ 186 | - Bumped jsonrpc-base to version 1.0.3 187 | 188 | 1.0.1 (2018-08-23) 189 | ~~~~~~~~~~~~~~~~~~ 190 | - Bumped jsonrpc-base to version 1.0.2 191 | 192 | 1.0.0 (2018-07-06) 193 | ~~~~~~~~~~~~~~~~~~ 194 | - Bumped jsonrpc-base to version 1.0.1 195 | 196 | 0.6 (2018-03-11) 197 | ~~~~~~~~~~~~~~~~ 198 | - Minimum required version of aiohttp is now 3.0. 199 | - Support for Python 3.4 is now dropped. 200 | 201 | Credits 202 | ------- 203 | `@gciotta `_ for creating the base project `jsonrpc-requests `_. 204 | 205 | `@mbroadst `_ for providing full support for nested method calls, JSON-RPC RFC 206 | compliance and other improvements. 207 | 208 | `@vaab `_ for providing api and tests improvements, better RFC compliance. 209 | -------------------------------------------------------------------------------- /jsonrpc_websocket/__init__.py: -------------------------------------------------------------------------------- 1 | from .jsonrpc import Server, TransportError # noqa: F401, F403 2 | -------------------------------------------------------------------------------- /jsonrpc_websocket/jsonrpc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | 4 | import aiohttp 5 | from aiohttp import ClientError 6 | from aiohttp.http_exceptions import HttpProcessingError 7 | import async_timeout 8 | import jsonrpc_base 9 | from jsonrpc_base import TransportError 10 | 11 | 12 | class Server(jsonrpc_base.Server): 13 | """A connection to a HTTP JSON-RPC server, backed by aiohttp""" 14 | 15 | def __init__(self, url, session=None, **connect_kwargs): 16 | super().__init__() 17 | self._session = session or aiohttp.ClientSession() 18 | 19 | # True if we made our own session 20 | self._internal_session = session is None 21 | 22 | self._client = None 23 | self._connect_kwargs = connect_kwargs 24 | self._url = url 25 | self._connect_kwargs['headers'] = self._connect_kwargs.get( 26 | 'headers', {}) 27 | self._connect_kwargs['headers']['Content-Type'] = ( 28 | self._connect_kwargs['headers'].get( 29 | 'Content-Type', 'application/json')) 30 | self._connect_kwargs['headers']['Accept'] = ( 31 | self._connect_kwargs['headers'].get( 32 | 'Accept', 'application/json-rpc')) 33 | self._timeout = self._connect_kwargs.get('timeout') 34 | self._pending_messages = {} 35 | 36 | async def send_message(self, message): 37 | """Send the HTTP message to the server and return the message response. 38 | 39 | No result is returned if message is a notification. 40 | """ 41 | if self._client is None: 42 | raise TransportError('Client is not connected.', message) 43 | 44 | try: 45 | await self._client.send_str(message.serialize()) 46 | if message.response_id: 47 | pending_message = PendingMessage() 48 | self._pending_messages[message.response_id] = pending_message 49 | response = await pending_message.wait(self._timeout) 50 | del self._pending_messages[message.response_id] 51 | else: 52 | response = None 53 | return message.parse_response(response) 54 | except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: 55 | raise TransportError('Transport Error', message, exc) 56 | 57 | async def ws_connect(self): 58 | """Connect to the websocket server.""" 59 | if self.connected: 60 | raise TransportError('Connection already open.') 61 | 62 | try: 63 | if self._internal_session and self._session.closed: 64 | self._session = aiohttp.ClientSession() 65 | self._client = await self._session.ws_connect( 66 | self._url, **self._connect_kwargs) 67 | except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: 68 | raise TransportError('Error connecting to server', None, exc) 69 | return self._session.loop.create_task(self._ws_loop()) 70 | 71 | async def _ws_loop(self): 72 | """Listen for messages from the websocket server.""" 73 | msg = None 74 | try: 75 | async for msg in self._client: 76 | if msg.type == aiohttp.WSMsgType.ERROR: 77 | break 78 | elif msg.type == aiohttp.WSMsgType.BINARY: 79 | try: 80 | # If we get a binary message, try and decode it as a 81 | # UTF-8 JSON string, in case the server is sending 82 | # binary websocket messages. If it doens't decode we'll 83 | # ignore it since we weren't expecting binary messages 84 | # anyway 85 | data = json.loads(msg.data.decode()) 86 | except ValueError: 87 | continue 88 | elif msg.type == aiohttp.WSMsgType.TEXT: 89 | try: 90 | data = msg.json() 91 | except ValueError as exc: 92 | raise TransportError('Error Parsing JSON', None, exc) 93 | else: 94 | # This is tested with test_message_ping_ignored, but 95 | # cpython's optimizations prevent coveragepy from detecting 96 | # that it's run 97 | # https://bitbucket.org/ned/coveragepy/issues/198/continue-marked-as-not-covered 98 | continue # pragma: no cover 99 | 100 | if 'method' in data: 101 | request = jsonrpc_base.Request.parse(data) 102 | response = await self.async_receive_request(request) 103 | if response: 104 | await self.send_message(response) 105 | else: 106 | self._pending_messages[data['id']].response = data 107 | 108 | except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: 109 | raise TransportError('Transport Error', None, exc) 110 | finally: 111 | await self.close() 112 | if msg and msg.type == aiohttp.WSMsgType.ERROR: 113 | raise TransportError( 114 | 'Websocket error detected. Connection closed.') 115 | 116 | async def close(self): 117 | """Close the connection to the websocket server.""" 118 | if self.connected: 119 | await self._client.close() 120 | self._client = None 121 | if self._internal_session: 122 | await self._session.close() 123 | 124 | @property 125 | def connected(self): 126 | """Websocket server is connected.""" 127 | return self._client is not None 128 | 129 | 130 | class PendingMessage(object): 131 | """Wait for response of pending message.""" 132 | 133 | def __init__(self): 134 | self._event = asyncio.Event() 135 | self._response = None 136 | 137 | async def wait(self, timeout=None): 138 | async with async_timeout.timeout(timeout): 139 | await self._event.wait() 140 | return self._response 141 | 142 | @property 143 | def response(self): 144 | return self._response 145 | 146 | @response.setter 147 | def response(self, value): 148 | self._response = value 149 | self._event.set() 150 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.5', 13 | author='Emily Love Mills', 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.7', 34 | 'Programming Language :: Python :: 3.8', 35 | 'Programming Language :: Python :: 3.9', 36 | 'Programming Language :: Python :: 3.10', 37 | ], 38 | 39 | ) 40 | -------------------------------------------------------------------------------- /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(event_loop): 109 | """Generate a mock json server.""" 110 | return JsonTestClient(event_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(event_loop, server): 354 | # Test that calls can be delivered simultaneously, and can return out 355 | # of order 356 | def handler(server, data): 357 | pass 358 | 359 | server._session.handler = handler 360 | 361 | with mock.patch("uuid.uuid4", return_value="abcd-1234"): 362 | task1 = event_loop.create_task(server.call1()) 363 | with mock.patch("uuid.uuid4", return_value="efgh-5678"): 364 | task2 = event_loop.create_task(server.call2()) 365 | 366 | assert task1.done() is False 367 | assert task2.done() is False 368 | 369 | server._session.receive( 370 | '{"jsonrpc": "2.0", "result": 2, "id": "efgh-5678"}') 371 | await task2 372 | 373 | assert task1.done() is False 374 | assert task2.done() 375 | 376 | server._session.receive( 377 | '{"jsonrpc": "2.0", "result": 1, "id": "abcd-1234"}') 378 | await task1 379 | 380 | assert task1.done() 381 | assert task2.done() 382 | 383 | assert 1 == task1.result() 384 | assert 2 == task2.result() 385 | 386 | 387 | async def test_notification(server): 388 | # Verify that we ignore the server response 389 | def handler(server, data): 390 | pass 391 | server._session.handler = handler 392 | assert (await server.subtract(42, 23, _notification=True)) is None 393 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py37, 4 | py38, 5 | py39, 6 | py310, 7 | flake8, 8 | 9 | [testenv] 10 | setenv = 11 | PYTHONPATH = {toxinidir}:{toxinidir}/jsonrpc_websocket 12 | commands = 13 | pytest --cov-report term-missing --cov=jsonrpc_websocket tests.py {posargs} 14 | deps = 15 | -r{toxinidir}/requirements-test.txt 16 | 17 | [testenv:py37] 18 | basepython = python3.7 19 | deps = 20 | {[testenv]deps} 21 | asynctest==0.13.0 22 | 23 | [testenv:py38] 24 | basepython = python3.8 25 | deps = 26 | {[testenv]deps} 27 | 28 | [testenv:py39] 29 | basepython = python3.9 30 | deps = 31 | {[testenv]deps} 32 | 33 | [testenv:py310] 34 | basepython = python3.10 35 | deps = 36 | {[testenv]deps} 37 | 38 | [testenv:flake8] 39 | basepython = python 40 | commands = flake8 jsonrpc_websocket tests.py 41 | --------------------------------------------------------------------------------