├── .bandit ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build_requirements.txt ├── contributors.md ├── examples ├── test_daemon_rpc.py ├── test_daemon_rpc_params.py ├── test_rpc.py └── test_rpc_batch.py ├── jsonrpc ├── __init__.py ├── authproxy.py ├── json.py └── proxy.py ├── monerorpc ├── .gitignore ├── __init__.py └── authproxy.py ├── requirements.in ├── requirements.txt ├── setup.py ├── tests.py ├── tox.ini └── update_requirements.sh /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude: */tests/*,*/migrations/*,*/__pycache__/*,*/.git/*,*/venv/*,*/dist/*,*/.pytest_cache/*,*/.tox/* 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | MANIFEST 4 | dist/ 5 | build/ 6 | *egg-info/ 7 | .venv 8 | .vscode 9 | .autoenv 10 | .coverage 11 | .mypy_cache 12 | .pytest_cache 13 | .tox 14 | 15 | venv/ 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/cryptosphere-systems/pre-commit-hooks 3 | rev: v2.4.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - id: check-executables-have-shebangs 8 | - id: check-merge-conflict 9 | - id: debug-statements 10 | - id: flake8 11 | - repo: https://github.com/cryptosphere-systems/black 12 | rev: 19.10b0 13 | hooks: 14 | - id: black 15 | exclude: | 16 | (?x)( 17 | migrations/| 18 | ^\.git/| 19 | ^cache/| 20 | ^\.cache/| 21 | ^\.venv/| 22 | ^\.local 23 | ) 24 | language_version: python3.7 25 | stages: [commit] 26 | - repo: https://github.com/cryptosphere-systems/pre-commit-hooks-bandit.git 27 | rev: v1.0.4 28 | hooks: 29 | - id: python-bandit-vulnerability-check 30 | # args: [-l, --recursive, -x, tests] 31 | files: .py$ 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 08.10.2020 2 | 3 | * Tagged `v0.6.0`. 4 | * No `python2` anymore. 5 | * Added maxium number of request retries (`=3`). 6 | * Moved setting connection attributes to when connection is created only. 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 normoes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub Release](https://img.shields.io/github/v/release/monero-ecosystem/python-monerorpc.svg)](https://github.com/monero-ecosystem/python-monerorpc/releases) 2 | [![GitHub Tags](https://img.shields.io/github/v/tag/monero-ecosystem/python-monerorpc.svg)](https://github.com/monero-ecosystem/python-monerorpc/tags) 3 | 4 | # python-monerorpc 5 | 6 | **DISCLAIMER**: The repository that should be worked on is located at the [**monero-ecosystem**](https://github.com/monero-ecosystem/python-monerorpc). 7 | 8 | **python-monerorpc** is an improved version of python-jsonrpc for Monero (`monerod rpc`, `monero-wallet-rpc`). 9 | 10 | **python-monerorpc** was originally forked from [**python-bitcoinrpc**](https://github.com/jgarzik/python-bitcoinrpc). 11 | 12 | It includes the following generic improvements: 13 | 14 | - HTTP connections persist for the life of the `AuthServiceProxy` object using `requests.Session` 15 | - sends protocol 'jsonrpc', per JSON-RPC 2.0 16 | - sends proper, incrementing 'id' 17 | - uses standard Python json lib 18 | - can optionally log all RPC calls and results 19 | - JSON-2.0 batch support (mimicking batch) 20 | - JSON-2.0 batch doesn't seem to work with monero. 21 | - The batch functionality is mimicked and just requests the given methods one after another. 22 | - The result is a list of dictionaries. 23 | 24 | It also includes some more specific details: 25 | 26 | - sends Digest HTTP authentication headers 27 | - parses all JSON numbers that look like floats as Decimal, 28 | and serializes Decimal values to JSON-RPC connections. 29 | 30 | ## What does it do? 31 | 32 | **python-monerorpc** communicates with monero over RPC. 33 | 34 | That includes: 35 | 36 | - `monerod rpc` as well as 37 | - `monero-wallet-rpc`. 38 | 39 | **python-monerorpc** takes over the actual HTTP request containing all the necessary headers. 40 | 41 | ## Compared to similar projects: 42 | 43 | - [**monero-python**](https://github.com/monero-ecosystem/monero-python) 44 | - **monero-python** 45 | - The module implements a json RPC backend (`monerod rpc`, `monero-wallet-rpc`). 46 | - It implements implementations around this backend (accounts, wallets, transactions, etc. ) 47 | - It offers helpful utilities like a monero wallet address validator. 48 | - A practical difference: 49 | 50 | - Should a RPC method change or a new one should be added, **monero-python** would have to adapt its backend and the implementations around it, while with **python-monerorpc** you just have to modify the property or use a new method like: 51 | 52 | ```python 53 | rpc_connection.getbalance() # -> rpc_connection.get_balance() 54 | rpc_connection.new_method() 55 | ``` 56 | ## Errors 57 | 58 | The `JSONRPCException` is thrown in the event of an error. 59 | 60 | One exception to that rule is when receiving a `JSONDecodeError` when converting the response to JSON. 61 | In this case a `ValueError` including the HTTP response is raised. 62 | 63 | This error was not handled before and directly raised a `JSONDecodeError`. Since `JSONDecodeError` inherits from `ValueError` nothing really changes. You should handle `ValueError` in addition to just `JSONRPCException` when working with `python-monerorpc`. 64 | 65 | **_TODO_**: 66 | An improved error handling. 67 | * Provide detailed information. 68 | * Separate into several causes like connection error, conversion error, etc. 69 | 70 | ## Installation: 71 | 72 | ### From PyPI 73 | 74 | To install `python-monerorpc` from PyPI using `pip` you just need to: 75 | 76 | > \$ pip install python-monerorpc 77 | 78 | ### From Source 79 | 80 | > \$ python setup.py install --user 81 | 82 | **Note**: This will only install `monerorpc`. If you also want to install `jsonrpc` to preserve 83 | backwards compatibility, you have to replace `monerorpc` with `jsonrpc` in `setup.py` and run it again. 84 | 85 | ## Examples: 86 | 87 | Example usage `monerod` (get info): 88 | 89 | ```python 90 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 91 | 92 | # initialisation, rpc_user and rpc_password are set as flags in the cli command 93 | rpc_connection = AuthServiceProxy(service_url='http://{0}:{1}@127.0.0.1:18081/json_rpc'.format(rpc_user, rpc_password)) 94 | 95 | info = rpc_connection.get_info() 96 | print(info) 97 | 98 | # rpc_user and rpc_password can also be left out (testing, develop, not recommended) 99 | rpc_connection = AuthServiceProxy(service_url='http://127.0.0.1:18081/json_rpc') 100 | ``` 101 | 102 | Example usage `monerod` (special characters in RPC password). 103 | 104 | This is also the recommended way to use passwords containing special characters like `some_password#-+`. 105 | 106 | When both ways are used (username/password in the URL and passed as arguments), the arguments' values will be predominant. 107 | 108 | ```python 109 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 110 | 111 | # When leaving rpc_user and rpc_password in the URL, 112 | # you can still pass those values as separate paramaters. 113 | rpc_connection = AuthServiceProxy(service_url='http://127.0.0.1:18081/json_rpc', username=rpc_user, password=rpc_password) 114 | 115 | # Or use both ways. 116 | rpc_connection = AuthServiceProxy(service_url='http://{0}@127.0.0.1:18081/json_rpc'.format(rpc_user), password=rpc_password) 117 | ``` 118 | 119 | Example usage `monerod` (get network type): 120 | 121 | ```python 122 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 123 | rpc_connection = AuthServiceProxy(service_url='http://{0}:{1}@127.0.0.1:18081/json_rpc'.format(rpc_user, rpc_password)) 124 | 125 | result = None 126 | network_type = None 127 | try: 128 | result = rpc_connection.get_info() 129 | except (requests.HTTPError, 130 | requests.ConnectionError, 131 | JSONRPCException) as e: 132 | logger.error('RPC Error on getting address' + str(e)) 133 | logger.exception(e) 134 | # Check network type 135 | network_type = result.get('nettype') 136 | if not network_type: 137 | raise ValueError('Error with: {0}'.format(result)) 138 | print(network_type) 139 | ``` 140 | 141 | Example usage `monerod` (on get block hash): 142 | 143 | ```python 144 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 145 | rpc_connection = AuthServiceProxy(service_url='http://{0}:{1}@127.0.0.1:18081/json_rpc'.format(rpc_user, rpc_password)) 146 | 147 | params = [2] 148 | hash = rpc.on_get_block_hash(params) 149 | print(hash) 150 | ``` 151 | 152 | Example usage `monero-wallet-rpc` (get balance): 153 | 154 | ```python 155 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 156 | 157 | # initialisation, rpc_user and rpc_password are set as flags in the cli command 158 | rpc_connection = AuthServiceProxy(service_url='http://{0}:{1}@127.0.0.1:18083/json_rpc'.format(rpc_user, rpc_password)) 159 | 160 | balance = rpc_connection.get_balance() 161 | print(balance) 162 | ``` 163 | 164 | Example usage `monero-wallet-rpc` (make transfer): 165 | 166 | ```python 167 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 168 | 169 | # initialisation, rpc_user and rpc_password are set as flags in the cli command 170 | rpc_connection = AuthServiceProxy(service_url='http://{0}:{1}@127.0.0.1:18083/json_rpc'.format(rpc_user, rpc_password)) 171 | 172 | destinations = {"destinations": [{"address": "some_address", "amount": 1}], "mixin": 10} 173 | result = rpc_connection.transfer(destinations) 174 | print(result) 175 | ``` 176 | 177 | Example usage `monero-wallet-rpc` (batch): 178 | 179 | ```python 180 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 181 | import pprint 182 | 183 | # initialisation, rpc_user and rpc_password are set as flags in the cli command 184 | rpc_connection = AuthServiceProxy(service_url='http://{0}:{1}@127.0.0.1:18083/json_rpc'.format(rpc_user, rpc_password)) 185 | 186 | # some example batch 187 | params={"account_index":0,"address_indices":[0,1]} 188 | result = rpc.batch_([ ["get_balance"], ["get_balance", params] ]) 189 | pprint.pprint(result) 190 | 191 | # make transfer and get balance in a batch 192 | destinations = {"destinations": [{"address": "some_address", "amount": 1}], "mixin": 10} 193 | result = rpc.batch_([ ["transfer", destinations], ["get_balance"] ]) 194 | pprint.pprint(result) 195 | ``` 196 | 197 | ## Logging: 198 | 199 | Logging all RPC calls to stderr: 200 | 201 | ```python 202 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 203 | import logging 204 | 205 | logging.basicConfig() 206 | logging.getLogger("MoneroRPC").setLevel(logging.DEBUG) 207 | 208 | rpc_connection = AuthServiceProxy(service_url='http://{0}:{1}@127.0.0.1:18081/json_rpc'.format(rpc_user, rpc_password)) 209 | 210 | print(rpc_connection.get_info()) 211 | ``` 212 | 213 | Produces output on stderr like: 214 | 215 | ```bash 216 | DEBUG:MoneroRPC:-1-> get_info [] 217 | DEBUG:MoneroRPC:<-1- {u'result': {u'incoming_connections_count': 0, ...etc } 218 | ``` 219 | 220 | ## Errors: 221 | 222 | Possible errors and error codes: 223 | 224 | - `no code` 225 | - Returns the `error` contained in the RPC response. 226 | - `-341` 227 | - `could not establish a connection, original error: {}` 228 | - including the original exception message 229 | - `-342` 230 | - `missing HTTP response from server` 231 | - `-343` 232 | - `missing JSON-RPC result` 233 | - `-344` 234 | - `received HTTP status code {}` 235 | - including HTTP status code other than `200` 236 | 237 | ### Testing and creating `requirements.txt` 238 | 239 | You won't ever need this probably - This is helpful when developing. 240 | 241 | `pip-tools` is used to create `requirements.txt`. 242 | * There is `requirements.in` where dependencies are set and pinned. 243 | * To create the `requirements.txt`, run `update_requirements.sh` which basically just calls `pip-compile`. 244 | 245 | **_Note_**: 246 | * There also is `build_requirements.txt` which only contains `pip-tools`. I found, when working with virtual environments, it is necessary to install `pip-tools` inside the virtual environment as well. Otherwise `pip-sync` would install outside the virtual environment. 247 | 248 | A test and development environment can be created like this: 249 | ```bash 250 | # Create a virtual environment 'venv'. 251 | python -m venv venv 252 | # Activate the virtual environment 'venv'. 253 | . /venv/bin/activate 254 | # Install 'pip-tools'. 255 | pip install --upgrade -r build_requirements.txt 256 | # Install dependencies. 257 | pip-sync requirements.txt 258 | ... 259 | # Deactivate the virtual environment 'venv'. 260 | deactivate 261 | ``` 262 | 263 | Run unit tests using `pytest`: 264 | 265 | ```bash 266 | # virtualenv activated (see above) 267 | pytest -s -v --cov monerorpc/ tests.py 268 | ``` 269 | 270 | Run unit tests on all supported python versions: 271 | 272 | ```bash 273 | tox -q 274 | ``` 275 | 276 | Run unit tests on a subset of the supported python versions: 277 | 278 | ```bash 279 | tox -q -e py36,py37 280 | ``` 281 | 282 | **Note:** The chosen python versions have to be installed on your system. 283 | 284 | ## Authors 285 | 286 | - **Norman Moeschter-Schenck** - _Initial work_ - [normoes](https://github.com/normoes) 287 | 288 | See also the list of [contributors](https://github.com/monero-ecosystem/python-monerorpc/blob/master/contributors.md) who participated in this project. 289 | -------------------------------------------------------------------------------- /build_requirements.txt: -------------------------------------------------------------------------------- 1 | pip-tools==5.3.1 2 | wheel==0.35.1 3 | -------------------------------------------------------------------------------- /contributors.md: -------------------------------------------------------------------------------- 1 | **Gonçalo Valério** - [dethos](https://github.com/dethos/) 2 | 3 | **Luis Alvarez** - [lalvarezguillen](https://github.com/lalvarezguillen/) 4 | -------------------------------------------------------------------------------- /examples/test_daemon_rpc.py: -------------------------------------------------------------------------------- 1 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 2 | import logging 3 | 4 | logging.basicConfig() 5 | logging.getLogger("MoneroRPC").setLevel(logging.DEBUG) 6 | log = logging.getLogger("wallet-rpc-lib") 7 | 8 | rpc = AuthServiceProxy("http://test:test@127.0.0.1:18081/json_rpc") 9 | # rpc = AuthServiceProxy('http://127.0.0.1:18081/json_rpc') 10 | try: 11 | rpc.get_info() 12 | except (JSONRPCException) as e: 13 | log.error(e) 14 | -------------------------------------------------------------------------------- /examples/test_daemon_rpc_params.py: -------------------------------------------------------------------------------- 1 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 2 | import logging 3 | 4 | logging.basicConfig() 5 | logging.getLogger("MoneroRPC").setLevel(logging.DEBUG) 6 | log = logging.getLogger("wallet-rpc-lib") 7 | 8 | rpc = AuthServiceProxy("http://test:test@127.0.0.1:18081/json_rpc") 9 | # rpc = AuthServiceProxy('http://127.0.0.1:18081/json_rpc') 10 | try: 11 | rpc.get_block_count() 12 | params = [2] 13 | hash = rpc.on_get_block_hash(params) 14 | print(hash) 15 | except (JSONRPCException) as e: 16 | log.error(e) 17 | -------------------------------------------------------------------------------- /examples/test_rpc.py: -------------------------------------------------------------------------------- 1 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 2 | import logging 3 | 4 | logging.basicConfig() 5 | logging.getLogger("MoneroRPC").setLevel(logging.DEBUG) 6 | log = logging.getLogger("wallet-rpc-lib") 7 | 8 | rpc = AuthServiceProxy("http://test:test@127.0.0.1:38083/json_rpc") 9 | # rpc = AuthServiceProxy('http://127.0.0.1:38083/json_rpc') 10 | try: 11 | rpc.get_balance() 12 | params = {"account_index": 0, "address_indices": [0, 1]} 13 | rpc.get_balance(params) 14 | destinations = { 15 | "destinations": [ 16 | { 17 | "address": "59McWTPGc745SRWrSMoh8oTjoXoQq6sPUgKZ66dQWXuKFQ2q19h9gvhJNZcFTizcnT12r63NFgHiGd6gBCjabzmzHAMoyD6", 18 | "amount": 1, 19 | } 20 | ], 21 | "mixin": 10, 22 | } 23 | rpc.transfer(destinations) 24 | except (JSONRPCException) as e: 25 | log.error(e) 26 | -------------------------------------------------------------------------------- /examples/test_rpc_batch.py: -------------------------------------------------------------------------------- 1 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 2 | import logging 3 | import pprint 4 | 5 | logging.basicConfig() 6 | logging.getLogger("MoneroRPC").setLevel(logging.DEBUG) 7 | log = logging.getLogger("wallet-rpc-lib") 8 | 9 | rpc = AuthServiceProxy("http://test:test@127.0.0.1:38083/json_rpc") 10 | # rpc = AuthServiceProxy('http://127.0.0.1:38083/json_rpc') 11 | try: 12 | 13 | params = {"account_index": 0, "address_indices": [0, 1]} 14 | result = rpc.batch_([["get_balance"], ["get_balance", params]]) 15 | pprint.pprint(result) 16 | 17 | destinations = { 18 | "destinations": [ 19 | { 20 | "address": "59McWTPGc745SRWrSMoh8oTjoXoQq6sPUgKZ66dQWXuKFQ2q19h9gvhJNZcFTizcnT12r63NFgHiGd6gBCjabzmzHAMoyD6", 21 | "amount": 1, 22 | } 23 | ], 24 | "mixin": 10, 25 | } 26 | result = rpc.batch_([["transfer", destinations], ["get_balance"]]) 27 | pprint.pprint(result) 28 | 29 | except (JSONRPCException) as e: 30 | log.error(e) 31 | -------------------------------------------------------------------------------- /jsonrpc/__init__.py: -------------------------------------------------------------------------------- 1 | from .json import loads, dumps, JSONEncodeException, JSONDecodeException 2 | from jsonrpc.proxy import ServiceProxy, JSONRPCException 3 | -------------------------------------------------------------------------------- /jsonrpc/authproxy.py: -------------------------------------------------------------------------------- 1 | from monerorpc.authproxy import AuthServiceProxy, JSONRPCException 2 | 3 | __all__ = ["AuthServiceProxy", "JSONRPCException"] 4 | -------------------------------------------------------------------------------- /jsonrpc/json.py: -------------------------------------------------------------------------------- 1 | _json = __import__("json") 2 | loads = _json.loads 3 | dumps = _json.dumps 4 | if hasattr(_json, "JSONEncodeException"): 5 | JSONEncodeException = _json.JSONEncodeException 6 | JSONDecodeException = _json.JSONDecodeException 7 | else: 8 | JSONEncodeException = TypeError 9 | JSONDecodeException = ValueError 10 | -------------------------------------------------------------------------------- /jsonrpc/proxy.py: -------------------------------------------------------------------------------- 1 | from monerorpc.authproxy import AuthServiceProxy as ServiceProxy, JSONRPCException 2 | -------------------------------------------------------------------------------- /monerorpc/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /monerorpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/normoes/python-monerorpc/8f2df0cf338a08d1c9cc08213efc2cbf37d7d6f0/monerorpc/__init__.py -------------------------------------------------------------------------------- /monerorpc/authproxy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2011 Jeff Garzik 3 | 4 | Forked by Norman Schenck from python-bitcoinrpc in 09/2018. 5 | python-monerorpc is based on this fork. 6 | 7 | 8 | AuthServiceProxy has the following improvements over python-jsonrpc's 9 | ServiceProxy class: 10 | 11 | - HTTP connections persist for the life of the AuthServiceProxy object 12 | (if server supports HTTP/1.1) 13 | - sends protocol 'jsonrpc', per JSON-RPC 2.0 14 | - sends proper, incrementing 'id' 15 | - sends Digest HTTP authentication headers 16 | - parses all JSON numbers that look like floats as Decimal 17 | - uses standard Python json lib 18 | 19 | Previous copyright, from python-jsonrpc/jsonrpc/proxy.py: 20 | 21 | Copyright (c) 2007 Jan-Klaas Kollhof 22 | 23 | This file is part of jsonrpc. 24 | 25 | jsonrpc is free software; you can redistribute it and/or modify 26 | it under the terms of the GNU Lesser General Public License as published by 27 | the Free Software Foundation; either version 2.1 of the License, or 28 | (at your option) any later version. 29 | 30 | This software is distributed in the hope that it will be useful, 31 | but WITHOUT ANY WARRANTY; without even the implied warranty of 32 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 33 | GNU Lesser General Public License for more details. 34 | 35 | You should have received a copy of the GNU Lesser General Public License 36 | along with this software; if not, write to the Free Software 37 | Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 38 | """ 39 | 40 | import decimal 41 | import json 42 | import logging 43 | import urllib.parse as urlparse 44 | 45 | from requests import auth, Session, codes 46 | from requests.adapters import HTTPAdapter 47 | from requests.exceptions import ConnectionError, Timeout, RequestException 48 | 49 | 50 | USER_AGENT = "AuthServiceProxy/0.1" 51 | 52 | HTTP_TIMEOUT = 30 53 | MAX_RETRIES = 3 54 | 55 | log = logging.getLogger("MoneroRPC") 56 | 57 | 58 | class JSONRPCException(Exception): 59 | def __init__(self, rpc_error): 60 | parent_args = [] 61 | if "message" in rpc_error: 62 | parent_args.append(rpc_error["message"]) 63 | Exception.__init__(self, *parent_args) 64 | self.error = rpc_error 65 | self.code = rpc_error["code"] if "code" in rpc_error else None 66 | self.message = rpc_error["message"] if "message" in rpc_error else None 67 | 68 | def __str__(self): 69 | return f"{self.code}: {self.message}" 70 | 71 | def __repr__(self): 72 | return f"<{self.__class__.__name__} '{self}'>" 73 | 74 | 75 | def EncodeDecimal(o): 76 | if isinstance(o, decimal.Decimal): 77 | return float(round(o, 12)) 78 | raise TypeError(repr(o) + " is not JSON serializable.") 79 | 80 | 81 | class AuthServiceProxy(object): 82 | """Extension of python-jsonrpc 83 | to communicate with Monero (monerod, monero-wallet-rpc) 84 | """ 85 | 86 | retry_adapter = HTTPAdapter(max_retries=MAX_RETRIES) 87 | 88 | __id_count = 0 89 | 90 | def __init__( 91 | self, 92 | service_url, 93 | username=None, 94 | password=None, 95 | service_name=None, 96 | timeout=HTTP_TIMEOUT, 97 | connection=None, 98 | ): 99 | """ 100 | :param service_url: Monero RPC URL, like http://user:passwd@host:port/json_rpc. 101 | :param service_name: Method name of Monero RPC. 102 | """ 103 | 104 | self.__service_url = service_url 105 | self.__service_name = service_name 106 | self.__timeout = timeout 107 | self.__url = urlparse.urlparse(service_url) 108 | 109 | port = self.__url.port if self.__url.port else 80 110 | self.__rpc_url = ( 111 | self.__url.scheme 112 | + "://" 113 | + self.__url.hostname 114 | + ":" 115 | + str(port) 116 | + self.__url.path 117 | ) 118 | 119 | if connection: 120 | # Callables re-use the connection of the original proxy 121 | self.__conn = connection 122 | else: 123 | headers = { 124 | "Content-Type": "application/json", 125 | "User-Agent": USER_AGENT, 126 | "Host": self.__url.hostname, 127 | } 128 | 129 | user = username if username else self.__url.username 130 | passwd = password if password else self.__url.password 131 | # Digest Authentication 132 | authentication = None 133 | if user is not None and passwd is not None: 134 | authentication = auth.HTTPDigestAuth(user, passwd) 135 | 136 | self.__conn = Session() 137 | self.__conn.mount( 138 | f"{self.__url.scheme}://{self.__url.hostname}", self.retry_adapter 139 | ) 140 | self.__conn.auth = authentication 141 | self.__conn.headers = headers 142 | 143 | def __getattr__(self, name): 144 | """Return the properly configured proxy according to the given RPC method. 145 | 146 | This maps requested object attributes to Monero RPC methods 147 | passed to the request. 148 | 149 | This is called before '__call__'. 150 | 151 | :param name: Method name of Monero RPC. 152 | """ 153 | 154 | if name.startswith("__") and name.endswith("__"): 155 | # Python internal stuff 156 | raise AttributeError 157 | if self.__service_name is not None: 158 | name = f"{self.__service_name}.{name}" 159 | return AuthServiceProxy( 160 | service_url=self.__service_url, service_name=name, connection=self.__conn, 161 | ) 162 | 163 | def __call__(self, *args): 164 | """Return the properly configured proxy according to the given RPC method. 165 | 166 | This maps requested object attributes to Monero RPC methods 167 | passed to the request. 168 | 169 | This is called on the object '__getattr__' returns. 170 | """ 171 | 172 | AuthServiceProxy.__id_count += 1 173 | 174 | log.debug( 175 | f"-{AuthServiceProxy.__id_count}-> {self.__service_name} {json.dumps(args, default=EncodeDecimal)}" 176 | ) 177 | # args is tuple 178 | # monero RPC always gets one dictionary as parameter 179 | if args: 180 | args = args[0] 181 | 182 | postdata = json.dumps( 183 | { 184 | "jsonrpc": "2.0", 185 | "method": self.__service_name, 186 | "params": args, 187 | "id": AuthServiceProxy.__id_count, 188 | }, 189 | default=EncodeDecimal, 190 | ) 191 | return self._request(postdata) 192 | 193 | def batch_(self, rpc_calls): 194 | """Batch RPC call. 195 | Pass array of arrays: [ [ "method", params... ], ... ] 196 | Returns array of results. 197 | 198 | No real implementation of JSON RPC batch. 199 | Only requesting every method one after another. 200 | """ 201 | results = list() 202 | for rpc_call in rpc_calls: 203 | method = rpc_call.pop(0) 204 | params = rpc_call.pop(0) if rpc_call else dict() 205 | results.append(self.__getattr__(method)(params)) 206 | 207 | return results 208 | 209 | def _request(self, postdata): 210 | log.debug(f"--> {postdata}") 211 | request_err_msg = None 212 | try: 213 | r = self.__conn.post( 214 | url=self.__rpc_url, data=postdata, timeout=self.__timeout 215 | ) 216 | except (ConnectionError) as e: 217 | request_err_msg = ( 218 | f"Could not establish a connection, original error: '{str(e)}'." 219 | ) 220 | except (Timeout) as e: 221 | request_err_msg = f"Connection timeout, original error: '{str(e)}'." 222 | except (RequestException) as e: 223 | request_err_msg = f"Request error: '{str(e)}'." 224 | 225 | if request_err_msg: 226 | raise JSONRPCException({"code": -341, "message": request_err_msg}) 227 | 228 | response = self._get_response(r) 229 | if response.get("error", None) is not None: 230 | raise JSONRPCException(response["error"]) 231 | elif "result" not in response: 232 | raise JSONRPCException( 233 | {"code": -343, "message": "Missing JSON-RPC result."} 234 | ) 235 | else: 236 | return response["result"] 237 | 238 | def _get_response(self, r): 239 | if r.status_code != codes.ok: 240 | raise JSONRPCException( 241 | { 242 | "code": -344, 243 | "message": f"Received HTTP status code '{r.status_code}'.", 244 | } 245 | ) 246 | http_response = r.text 247 | if http_response is None: 248 | raise JSONRPCException( 249 | {"code": -342, "message": "Missing HTTP response from server."} 250 | ) 251 | 252 | try: 253 | response = json.loads(http_response, parse_float=decimal.Decimal) 254 | except (json.JSONDecodeError) as e: 255 | raise ValueError(f"Error: '{str(e)}'. Response: '{http_response}'.") 256 | 257 | if "error" in response and response.get("error", None) is None: 258 | log.error(f"Error: '{response}'") 259 | log.debug( 260 | f"<-{response['id']}- {json.dumps(response['result'], default=EncodeDecimal)}" 261 | ) 262 | else: 263 | log.debug(f"<-- {response}") 264 | return response 265 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | requests>=2.24.0 2 | pytest>=5.3.2 3 | pytest-cov>=2.8.1 4 | responses>=0.10.9 5 | tox>=3.14.3 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile --output-file=requirements.txt requirements.in 6 | # 7 | appdirs==1.4.4 8 | # via virtualenv 9 | attrs==20.2.0 10 | # via pytest 11 | certifi==2020.6.20 12 | # via requests 13 | chardet==3.0.4 14 | # via requests 15 | coverage==5.3 16 | # via pytest-cov 17 | distlib==0.3.1 18 | # via virtualenv 19 | filelock==3.0.12 20 | # via 21 | # tox 22 | # virtualenv 23 | idna==2.10 24 | # via requests 25 | iniconfig==1.0.1 26 | # via pytest 27 | packaging==20.4 28 | # via 29 | # pytest 30 | # tox 31 | pluggy==0.13.1 32 | # via 33 | # pytest 34 | # tox 35 | py==1.10.0 36 | # via 37 | # pytest 38 | # tox 39 | pyparsing==2.4.7 40 | # via packaging 41 | pytest-cov==2.10.1 42 | # via -r requirements.in 43 | pytest==6.1.1 44 | # via 45 | # -r requirements.in 46 | # pytest-cov 47 | requests==2.24.0 48 | # via 49 | # -r requirements.in 50 | # responses 51 | responses==0.12.0 52 | # via -r requirements.in 53 | six==1.15.0 54 | # via 55 | # packaging 56 | # responses 57 | # tox 58 | # virtualenv 59 | toml==0.10.1 60 | # via 61 | # pytest 62 | # tox 63 | tox==3.20.0 64 | # via -r requirements.in 65 | urllib3==1.25.10 66 | # via 67 | # requests 68 | # responses 69 | virtualenv==20.0.33 70 | # via tox 71 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | __version__ = "v0.6.1" 6 | 7 | setup( 8 | name="python-monerorpc", 9 | version=__version__, 10 | description="Enhanced version of python-jsonrpc for Monero (monerod, monero-wallet-rpc).", 11 | long_description=open("README.md").read(), 12 | long_description_content_type="text/markdown", 13 | author="Norman Moeschter-Schenck", 14 | author_email="", 15 | maintainer="Norman Moeschter-Schenck", 16 | maintainer_email="", 17 | url="https://www.github.com/monero-ecosystem/python-monerorpc", 18 | download_url=f"https://github.com/monero-ecosystem/python-monerorpc/archive/{__version__}.tar.gz", 19 | packages=["monerorpc"], 20 | install_requires=["requests>=2.23.0"], 21 | classifiers=[ 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python :: 3.6", 25 | "Programming Language :: Python :: 3.7", 26 | ], 27 | ) 28 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | from decimal import Decimal 3 | 4 | import pytest 5 | import responses 6 | from requests.exceptions import ConnectionError, Timeout, RequestException 7 | from monerorpc.authproxy import ( 8 | AuthServiceProxy, 9 | EncodeDecimal, 10 | JSONRPCException, 11 | ) 12 | 13 | 14 | class TestEncodeDecimal: 15 | def test_encodes_ok(self): 16 | assert json.dumps(Decimal(2), default=EncodeDecimal) 17 | 18 | def test_encoding_fail(self): 19 | with pytest.raises(TypeError): 20 | json.dumps(self, default=EncodeDecimal) 21 | 22 | 23 | class TestAuthServiceProxy: 24 | dummy_url = "http://dummy-rpc:8000/json_rpc" 25 | 26 | @responses.activate 27 | def test_good_call(self): 28 | responses.add(responses.POST, self.dummy_url, json={"result": "dummy"}) 29 | client = AuthServiceProxy(self.dummy_url) 30 | resp = client.status() 31 | assert resp == "dummy" 32 | 33 | @responses.activate 34 | @pytest.mark.parametrize("code", (500, 404)) 35 | def test_http_error_raises_error(self, code): 36 | responses.add(responses.POST, self.dummy_url, status=code) 37 | client = AuthServiceProxy(self.dummy_url) 38 | with pytest.raises(JSONRPCException): 39 | client.dummy_method() 40 | 41 | @responses.activate 42 | def test_empty_response_raises_error(self): 43 | responses.add(responses.POST, self.dummy_url, status=200, json={}) 44 | client = AuthServiceProxy(self.dummy_url) 45 | with pytest.raises(JSONRPCException): 46 | client.dummy_method() 47 | 48 | @responses.activate 49 | def test_rpc_error_raises_error(self): 50 | responses.add( 51 | responses.POST, self.dummy_url, status=200, json={"error": "dummy error"}, 52 | ) 53 | client = AuthServiceProxy(self.dummy_url) 54 | with pytest.raises(JSONRPCException): 55 | client.dummy_method() 56 | 57 | @responses.activate 58 | def test_connection_error(self): 59 | """Mock no connection to server error. 60 | """ 61 | responses.add(responses.POST, self.dummy_url, body=ConnectionError("")) 62 | client = AuthServiceProxy(self.dummy_url) 63 | with pytest.raises(JSONRPCException): 64 | client.get_balance() 65 | 66 | @responses.activate 67 | def test_timeout_error(self): 68 | """Mock timeout connecting to server. 69 | """ 70 | responses.add(responses.POST, self.dummy_url, body=Timeout("")) 71 | client = AuthServiceProxy(self.dummy_url) 72 | with pytest.raises(JSONRPCException): 73 | client.get_balance() 74 | 75 | @responses.activate 76 | def test_jsondecode_request_error(self): 77 | """Mock JSONDecodeError when trying to get JSON form response. 78 | """ 79 | responses.add(responses.POST, self.dummy_url, body=RequestException("")) 80 | client = AuthServiceProxy(self.dummy_url) 81 | with pytest.raises(JSONRPCException): 82 | client.get_balance() 83 | 84 | @responses.activate 85 | def test_other_request_error(self): 86 | """Mock other errors connecting to server. 87 | """ 88 | responses.add(responses.POST, self.dummy_url, body="") 89 | client = AuthServiceProxy(self.dummy_url) 90 | with pytest.raises(ValueError): 91 | client.get_balance() 92 | 93 | @responses.activate 94 | def test_calls_batch(self): 95 | for n in range(2): 96 | responses.add( 97 | responses.POST, 98 | self.dummy_url, 99 | status=200, 100 | json={"result": "dummy - {}".format(n)}, 101 | ) 102 | client = AuthServiceProxy(self.dummy_url) 103 | cases = [["dummy_method_1", {}], ["dummy_method_2", "dummy"]] 104 | results = client.batch_(cases) 105 | assert len(results) == len(cases) 106 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # E501: Line length 3 | # W503: line break before binary operator (black formats like this, is also in pycodestyle) 4 | # E401: multiple imports on one line (black takes care of this) 5 | # E302: expected 2 blank lines, found 1 (black takes care of this) 6 | ignore = E501,W503,E401,E302 7 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,venv,.venv,.local,.cache,,jsonrpc/** 8 | # filename: comma-separated filename and glob patterns default: *.py 9 | max-complexity = 10 10 | -------------------------------------------------------------------------------- /update_requirements.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -Eeo pipefail 4 | 5 | echo -e "\e[32mCompile python dependencies.\e[39m" 6 | pip-compile --rebuild --upgrade --output-file requirements.txt requirements.in 7 | 8 | echo -e "\e[32mDone. Check requirements.txt.\e[39m" 9 | --------------------------------------------------------------------------------