diff --git a/pyircbot/jsonrpc.py b/pyircbot/jsonrpc.py index e26b97b..229c3f9 100755 --- a/pyircbot/jsonrpc.py +++ b/pyircbot/jsonrpc.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# -*- coding: ascii -*- """ JSON-RPC (remote procedure call). @@ -11,13 +9,13 @@ It consists of 3 (independent) parts: It's intended for JSON-RPC, but since the above 3 parts are independent, it could be used for other RPCs as well. -Currently, JSON-RPC 2.0(pre) and JSON-RPC 1.0 are implemented +Currently, JSON-RPC 2.0 and JSON-RPC 1.0 are implemented -:Version: 2013-07-17-beta -:Status: experimental +:version: 2017-12-03-RELEASE +:status: experimental -:Example: - simple Client with JsonRPC2.0 and TCP/IP:: +:example: + simple Client with JsonRPC 2.0 and TCP/IP:: >>> proxy = ServerProxy( JsonRpc20(), TransportTcpIp(addr=("127.0.0.1",31415)) ) >>> proxy.echo( "hello world" ) @@ -44,7 +42,7 @@ Currently, JSON-RPC 2.0(pre) and JSON-RPC 1.0 are implemented close ('127.0.0.1', 31415) Client with JsonRPC2.0 and an abstract Unix Domain Socket:: - + >>> proxy = ServerProxy( JsonRpc20(), TransportUnixSocket(addr="\\x00.rpcsocket") ) >>> proxy.hi( message="hello" ) #named parameters u'hi there' @@ -56,7 +54,7 @@ Currently, JSON-RPC 2.0(pre) and JSON-RPC 1.0 are implemented u'hello world' Server with JsonRPC2.0 and abstract Unix Domain Socket with a logfile:: - + >>> server = Server( JsonRpc20(), TransportUnixSocket(addr="\\x00.rpcsocket", logfunc=log_file("mylog.txt")) ) >>> def echo( s ): ... return s @@ -82,109 +80,94 @@ Currently, JSON-RPC 2.0(pre) and JSON-RPC 1.0 are implemented '' close close '\\x00.rpcsocket' -:Note: all exceptions derived from RPCFault are propagated to the client. +:note: all exceptions derived from RPCFault are propagated to the client. other exceptions are logged and result in a sent-back "empty" INTERNAL_ERROR. -:Uses: simplejson, socket, sys,time,codecs -:SeeAlso: JSON-RPC 2.0 proposal, 1.0 specification -:Warning: +:uses: logging, sys, json, codecs, time, socket, select +:seealso: JSON-RPC 2.0 proposal, 1.0 specification +:warning: .. Warning:: This is **experimental** code! -:Bug: +:author: Dave Pedu (dave(at)davepedu.com) +:changelog: + - 2008-08-31: 1st release + - 2017-12-03-RELEASE Modern python 3.0 rewrite -:Author: Roland Koebler (rk(at)simple-is-better.org) -:Copyright: 2007-2008 by Roland Koebler (rk(at)simple-is-better.org) -:License: see __license__ -:Changelog: - - 2008-08-31: 1st release - -TODO: - - server: multithreading rpc-server - - client: multicall (send several requests) - - transport: SSL sockets, maybe HTTP, HTTPS - - types: support for date/time (ISO 8601) - - errors: maybe customizable error-codes/exceptions - - mixed 1.0/2.0 server ? - - system description etc. ? - - maybe test other json-serializers, like cjson? +:todo: + - server: multithreading rpc-server + - client: multicall (send several requests) + - transport: SSL sockets, maybe HTTP, HTTPS + - types: support for date/time (ISO 8601) + - errors: maybe customizable error-codes/exceptions + - mixed 1.0/2.0 server ? + - system description etc. ? + - maybe test other json-serializers, like cjson? """ -__version__ = "2008-08-31-beta" -__author__ = "Roland Koebler " -__license__ = """Copyright (c) 2007-2008 by Roland Koebler (rk(at)simple-is-better.org) +__version__ = "2017-12-03-RELEASE" +__author__ = "Dave Pedu (dave(at)davepedu.com)" -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" - -#========================================= -#import +# ========================================= +# imports import logging import sys +import json #TODO faster implementation +import codecs +import time +import socket +import select -#========================================= + +# ========================================= # errors -#---------------------- -# error-codes + exceptions - -#JSON-RPC 2.0 error-codes +# JSON-RPC 2.0 error-codes PARSE_ERROR = -32700 INVALID_REQUEST = -32600 METHOD_NOT_FOUND = -32601 -INVALID_METHOD_PARAMS = -32602 #invalid number/type of parameters -INTERNAL_ERROR = -32603 #"all other errors" +INVALID_METHOD_PARAMS = -32602 # invalid number/type of parameters +INTERNAL_ERROR = -32603 # "all other errors" -#additional error-codes +# additional error-codes PROCEDURE_EXCEPTION = -32000 AUTHENTIFICATION_ERROR = -32001 PERMISSION_DENIED = -32002 INVALID_PARAM_VALUES = -32003 -#human-readable messages +# human-readable messages ERROR_MESSAGE = { - PARSE_ERROR : "Parse error.", - INVALID_REQUEST : "Invalid Request.", - METHOD_NOT_FOUND : "Method not found.", - INVALID_METHOD_PARAMS : "Invalid parameters.", - INTERNAL_ERROR : "Internal error.", + PARSE_ERROR: "Parse error.", + INVALID_REQUEST: "Invalid Request.", + METHOD_NOT_FOUND: "Method not found.", + INVALID_METHOD_PARAMS: "Invalid parameters.", + INTERNAL_ERROR: "Internal error.", + PROCEDURE_EXCEPTION: "Procedure exception.", + AUTHENTIFICATION_ERROR: "Authentification error.", + PERMISSION_DENIED: "Permission denied.", + INVALID_PARAM_VALUES: "Invalid parameter values." +} - PROCEDURE_EXCEPTION : "Procedure exception.", - AUTHENTIFICATION_ERROR : "Authentification error.", - PERMISSION_DENIED : "Permission denied.", - INVALID_PARAM_VALUES: "Invalid parameter values." - } - -#---------------------- + +# ---------------------- # exceptions + class RPCError(Exception): """Base class for rpc-errors.""" class RPCTransportError(RPCError): """Transport error.""" + + class RPCTimeoutError(RPCTransportError): """Transport/reply timeout.""" + class RPCFault(RPCError): """RPC error/fault package received. - + This exception can also be used as a class, to generate a RPC-error/fault message. @@ -197,34 +180,41 @@ class RPCFault(RPCError): """ def __init__(self, error_code, error_message, error_data=None): RPCError.__init__(self) - self.error_code = error_code + self.error_code = error_code self.error_message = error_message - self.error_data = error_data + self.error_data = error_data + def __str__(self): return repr(self) + def __repr__(self): - return( "" % (self.error_code, repr(self.error_message), repr(self.error_data)) ) + return "" % (self.error_code, repr(self.error_message), repr(self.error_data)) + class RPCParseError(RPCFault): """Broken rpc-package. (PARSE_ERROR)""" def __init__(self, error_data=None): RPCFault.__init__(self, PARSE_ERROR, ERROR_MESSAGE[PARSE_ERROR], error_data) + class RPCInvalidRPC(RPCFault): """Invalid rpc-package. (INVALID_REQUEST)""" def __init__(self, error_data=None): RPCFault.__init__(self, INVALID_REQUEST, ERROR_MESSAGE[INVALID_REQUEST], error_data) + class RPCMethodNotFound(RPCFault): """Method not found. (METHOD_NOT_FOUND)""" def __init__(self, error_data=None): RPCFault.__init__(self, METHOD_NOT_FOUND, ERROR_MESSAGE[METHOD_NOT_FOUND], error_data) + class RPCInvalidMethodParams(RPCFault): """Invalid method-parameters. (INVALID_METHOD_PARAMS)""" def __init__(self, error_data=None): RPCFault.__init__(self, INVALID_METHOD_PARAMS, ERROR_MESSAGE[INVALID_METHOD_PARAMS], error_data) + class RPCInternalError(RPCFault): """Internal error. (INTERNAL_ERROR)""" def __init__(self, error_data=None): @@ -235,488 +225,458 @@ class RPCProcedureException(RPCFault): """Procedure exception. (PROCEDURE_EXCEPTION)""" def __init__(self, error_data=None): RPCFault.__init__(self, PROCEDURE_EXCEPTION, ERROR_MESSAGE[PROCEDURE_EXCEPTION], error_data) + + class RPCAuthentificationError(RPCFault): """AUTHENTIFICATION_ERROR""" def __init__(self, error_data=None): RPCFault.__init__(self, AUTHENTIFICATION_ERROR, ERROR_MESSAGE[AUTHENTIFICATION_ERROR], error_data) + + class RPCPermissionDenied(RPCFault): """PERMISSION_DENIED""" def __init__(self, error_data=None): RPCFault.__init__(self, PERMISSION_DENIED, ERROR_MESSAGE[PERMISSION_DENIED], error_data) + + class RPCInvalidParamValues(RPCFault): """INVALID_PARAM_VALUES""" def __init__(self, error_data=None): RPCFault.__init__(self, INVALID_PARAM_VALUES, ERROR_MESSAGE[INVALID_PARAM_VALUES], error_data) -#========================================= -# data structure / serializer +ERROR_CODE_EXCEPTIONS = {PARSE_ERROR: RPCParseError, + INVALID_REQUEST: RPCInvalidRPC, + METHOD_NOT_FOUND: RPCMethodNotFound, + INVALID_METHOD_PARAMS: RPCInvalidMethodParams, + INTERNAL_ERROR: RPCInternalError, + PROCEDURE_EXCEPTION: RPCProcedureException, + AUTHENTIFICATION_ERROR: RPCAuthentificationError, + PERMISSION_DENIED: RPCPermissionDenied, + INVALID_PARAM_VALUES: RPCInvalidParamValues} -try: - import json as simplejson -except ImportError as err: - print("FATAL: json-module 'simplejson' and 'json' is missing (%s)" % (err)) - sys.exit(1) - -#---------------------- -# -def dictkeyclean(d): - """Convert all keys of the dict 'd' to (ascii-)strings. - - :Raises: UnicodeEncodeError - """ - new_d = {} - for (k, v) in d.iteritems(): - new_d[str(k)] = v - return new_d - -#---------------------- -# JSON-RPC 1.0 class JsonRpc10: - """JSON-RPC V1.0 data-structure / serializer + """ + JSON-RPC V1.0 data-structure / serializer This implementation is quite liberal in what it accepts: It treats missing "params" and "id" in Requests and missing "result"/"error" in Responses as empty/null. - :SeeAlso: JSON-RPC 1.0 specification - :TODO: catch simplejson.dumps not-serializable-exceptions + :seealso: JSON-RPC 1.0 specification + :todo: catch json.dumps not-serializable-exceptions """ - def __init__(self, dumps=simplejson.dumps, loads=simplejson.loads): - """init: set serializer to use + def __init__(self, dumps=json.dumps, loads=json.loads): + """ + init: set serializer to use - :Parameters: - - dumps: json-encoder-function - - loads: json-decoder-function - :Note: The dumps_* functions of this class already directly create - the invariant parts of the resulting json-object themselves, - without using the given json-encoder-function. + :param dumps: json-encoder-function + :param loads: json-decoder-function + :note: The dumps_* functions of this class already directly create the invariant parts of the resulting + json-object themselves, without using the given json-encoder-function. """ self.dumps = dumps self.loads = loads - def dumps_request( self, method, params=(), id=0 ): - """serialize JSON-RPC-Request - - :Parameters: - - method: the method-name (str/unicode) - - params: the parameters (list/tuple) - - id: if id=None, this results in a Notification - :Returns: | {"method": "...", "params": ..., "id": ...} - | "method", "params" and "id" are always in this order. - :Raises: TypeError if method/params is of wrong type or - not JSON-serializable + def dumps_request(self, method, params=(), id=0): """ - if not isinstance(method, (str, unicode)): + serialize JSON-RPC-Request + + :param method: the method-name + :type method: str + :param params: the parameters + :type params: list, tuple + :param id: if id isNone, this results in a Notification + :return: str like`{"method": "...", "params": ..., "id": ...}`. "method", "params" and "id" are always in this + order. + :raises: TypeError if method/params is of wrong type or not JSON-serializable + """ + if not isinstance(method, str): + raise TypeError('"method" must be a string (or unicode string).') + if not isinstance(params, (tuple, list)): + raise TypeError("params must be a tuple/list, got {}".format(type(params))) + + return '{{"method": {}, "params": {}, "id": {}}}'.format(self.dumps(method), self.dumps(params), self.dumps(id)) + + def dumps_notification(self, method, params=()): + """ + serialize a JSON-RPC-Notification + + :param method: the method-name + :type method: str + :param params: the parameters + :type params: list, tuple + :return: str like `{"method": "...", "params": ..., "id": null}`. "method", "params" and "id" are always in this + order. + :raises: TypeError if method/params is of wrong type or not JSON-serializable + """ + if not isinstance(method, str): raise TypeError('"method" must be a string (or unicode string).') if not isinstance(params, (tuple, list)): raise TypeError("params must be a tuple/list.") - return '{"method": %s, "params": %s, "id": %s}' % \ - (self.dumps(method), self.dumps(params), self.dumps(id)) + return '{{"method": {}, "params": {}, "id": null}}'.format(self.dumps(method), self.dumps(params)) - def dumps_notification( self, method, params=() ): - """serialize a JSON-RPC-Notification - - :Parameters: see dumps_request - :Returns: | {"method": "...", "params": ..., "id": null} - | "method", "params" and "id" are always in this order. - :Raises: see dumps_request + def dumps_response(self, result, id=None): """ - if not isinstance(method, (str, unicode)): - raise TypeError('"method" must be a string (or unicode string).') - if not isinstance(params, (tuple, list)): - raise TypeError("params must be a tuple/list.") + serialize a JSON-RPC-Response (without error) - return '{"method": %s, "params": %s, "id": null}' % \ - (self.dumps(method), self.dumps(params)) - - def dumps_response( self, result, id=None ): - """serialize a JSON-RPC-Response (without error) - - :Returns: | {"result": ..., "error": null, "id": ...} - | "result", "error" and "id" are always in this order. - :Raises: TypeError if not JSON-serializable + :return: str like `{"result": ..., "error": null, "id": ...}`. "result", "error" and "id" are always in this + order. + :raises: TypeError if not JSON-serializable """ - return '{"result": %s, "error": null, "id": %s}' % \ - (self.dumps(result), self.dumps(id)) + return '{{"result": {}, "error": null, "id": {}}}'.format(self.dumps(result), self.dumps(id)) - def dumps_error( self, error, id=None ): - """serialize a JSON-RPC-Response-error + def dumps_error(self, error, id=None): + """ + serialize a JSON-RPC-Response-error Since JSON-RPC 1.0 does not define an error-object, this uses the JSON-RPC 2.0 error-object. - - :Parameters: - - error: a RPCFault instance - :Returns: | {"result": null, "error": {"code": error_code, "message": error_message, "data": error_data}, "id": ...} - | "result", "error" and "id" are always in this order, data is omitted if None. - :Raises: ValueError if error is not a RPCFault instance, - TypeError if not JSON-serializable + + :param error: an RPCFault instance + :type error: RPCFault + :returns: str like `{"result": null, "error": {"code": code, "message": message, "data": data}, "id": ...}`. + "result", "error" and "id" are always in this order, data is omitted if None. + :raises: ValueError if error is not a RPCFault instance, + TypeError if not JSON-serializable """ if not isinstance(error, RPCFault): - raise ValueError("""error must be a RPCFault-instance.""") + raise ValueError("error must be a RPCFault-instance.") if error.error_data is None: - return '{"result": null, "error": {"code":%s, "message": %s}, "id": %s}' % \ - (self.dumps(error.error_code), self.dumps(error.error_message), self.dumps(id)) + return '{{"result": null, "error": {{"code": {}, "message": {}}}, "id": {}}}' \ + .format(self.dumps(error.error_code), self.dumps(error.error_message), self.dumps(id)) else: - return '{"result": null, "error": {"code":%s, "message": %s, "data": %s}, "id": %s}' % \ - (self.dumps(error.error_code), self.dumps(error.error_message), self.dumps(error.error_data), self.dumps(id)) + return '{{"result": null, "error": {{"code": {}, "message": {}, "data": {}}}, "id": {}}}' \ + .format(self.dumps(error.error_code), self.dumps(error.error_message), + self.dumps(error.error_data), self.dumps(id)) - def loads_request( self, string ): - """de-serialize a JSON-RPC Request/Notification + def loads_request(self, string): + """ + de-serialize a JSON-RPC Request/Notification - :Returns: | [method_name, params, id] or [method_name, params] - | params is a tuple/list - | if id is missing, this is a Notification - :Raises: RPCParseError, RPCInvalidRPC, RPCInvalidMethodParams + :returns: list like `[method_name, params, id]` or `[method_name, params]`. params is a tuple/list. if id is + missing, this is a Notification + :raises: RPCParseError, RPCInvalidRPC, RPCInvalidMethodParams """ try: data = self.loads(string) except ValueError as err: raise RPCParseError("No valid JSON. (%s)" % str(err)) - if not isinstance(data, dict): raise RPCInvalidRPC("No valid RPC-package.") - if "method" not in data: raise RPCInvalidRPC("""Invalid Request, "method" is missing.""") - if not isinstance(data["method"], (str, unicode)): - raise RPCInvalidRPC("""Invalid Request, "method" must be a string.""") - if "id" not in data: data["id"] = None #be liberal - if "params" not in data: data["params"] = () #be liberal + if not isinstance(data, dict): + raise RPCInvalidRPC("No valid RPC-package.") + if "method" not in data: + raise RPCInvalidRPC('Invalid Request, "method" is missing.') + if not isinstance(data["method"], str, ): + raise RPCInvalidRPC('Invalid Request, "method" must be a string.') + if "id" not in data: + data["id"] = None + if "params" not in data: + data["params"] = () if not isinstance(data["params"], (list, tuple)): - raise RPCInvalidRPC("""Invalid Request, "params" must be an array.""") - if len(data) != 3: raise RPCInvalidRPC("""Invalid Request, additional fields found.""") + raise RPCInvalidRPC('Invalid Request, "params" must be an array.') + if len(data) != 3: + raise RPCInvalidRPC('Invalid Request, additional fields found.') # notification / request if data["id"] is None: - return data["method"], data["params"] #notification + return data["method"], data["params"] # notification else: - return data["method"], data["params"], data["id"] #request + return data["method"], data["params"], data["id"] # request - def loads_response( self, string ): - """de-serialize a JSON-RPC Response/error + def loads_response(self, string): + """ + de-serialize a JSON-RPC Response/error - :Returns: | [result, id] for Responses - :Raises: | RPCFault+derivates for error-packages/faults, RPCParseError, RPCInvalidRPC - | Note that for error-packages which do not match the - V2.0-definition, RPCFault(-1, "Error", RECEIVED_ERROR_OBJ) - is raised. + :return: list like `[result, id]` for Responses + :raises: | RPCFault+derivates for error-packages/faults, RPCParseError, RPCInvalidRPC + :note: error-packages which do not match the V2.0-definition, RPCFault(-1, "Error", RECEIVED_ERROR_OBJ) is + instead raised. """ try: data = self.loads(string) except ValueError as err: raise RPCParseError("No valid JSON. (%s)" % str(err)) - if not isinstance(data, dict): raise RPCInvalidRPC("No valid RPC-package.") - if "id" not in data: raise RPCInvalidRPC("""Invalid Response, "id" missing.""") - if "result" not in data: data["result"] = None #be liberal - if "error" not in data: data["error"] = None #be liberal - if len(data) != 3: raise RPCInvalidRPC("""Invalid Response, additional or missing fields.""") + if not isinstance(data, dict): + raise RPCInvalidRPC("No valid RPC-package.") + if "id" not in data: + raise RPCInvalidRPC('Invalid Response, "id" missing.') + if "result" not in data: + data["result"] = None + if "error" not in data: + data["error"] = None + if len(data) != 3: + raise RPCInvalidRPC('Invalid Response, additional or missing fields.') - #error if data["error"] is not None: if data["result"] is not None: - raise RPCInvalidRPC("""Invalid Response, one of "result" or "error" must be null.""") - #v2.0 error-format - if( isinstance(data["error"], dict) and "code" in data["error"] and "message" in data["error"] and - (len(data["error"])==2 or ("data" in data["error"] and len(data["error"])==3)) ): + raise RPCInvalidRPC('Invalid Response, one of "result" or "error" must be null.') + # v2.0 error-format + if (isinstance(data["error"], dict) and "code" in data["error"] and "message" in data["error"] and + (len(data["error"]) == 2 or ("data" in data["error"] and len(data["error"]) == 3))): if "data" not in data["error"]: error_data = None else: error_data = data["error"]["data"] - - if data["error"]["code"] == PARSE_ERROR: - raise RPCParseError(error_data) - elif data["error"]["code"] == INVALID_REQUEST: - raise RPCInvalidRPC(error_data) - elif data["error"]["code"] == METHOD_NOT_FOUND: - raise RPCMethodNotFound(error_data) - elif data["error"]["code"] == INVALID_METHOD_PARAMS: - raise RPCInvalidMethodParams(error_data) - elif data["error"]["code"] == INTERNAL_ERROR: - raise RPCInternalError(error_data) - elif data["error"]["code"] == PROCEDURE_EXCEPTION: - raise RPCProcedureException(error_data) - elif data["error"]["code"] == AUTHENTIFICATION_ERROR: - raise RPCAuthentificationError(error_data) - elif data["error"]["code"] == PERMISSION_DENIED: - raise RPCPermissionDenied(error_data) - elif data["error"]["code"] == INVALID_PARAM_VALUES: - raise RPCInvalidParamValues(error_data) + error_code = data["error"]["code"] + if error_code in ERROR_CODE_EXCEPTIONS: + raise ERROR_CODE_EXCEPTIONS[error_code](error_data) else: raise RPCFault(data["error"]["code"], data["error"]["message"], error_data) - #other error-format - else: + else: # other error-format raise RPCFault(-1, "Error", data["error"]) - #result - else: + else: #successful result return data["result"], data["id"] -#---------------------- -# JSON-RPC 2.0 -class JsonRpc20: - """JSON-RPC V2.0 data-structure / serializer - - :SeeAlso: JSON-RPC 2.0 specification - :TODO: catch simplejson.dumps not-serializable-exceptions +class JsonRpc20(object): """ - def __init__(self, dumps=simplejson.dumps, loads=simplejson.loads): - """init: set serializer to use + JSON-RPC V2.0 data-structure / serializer - :Parameters: - - dumps: json-encoder-function - - loads: json-decoder-function - :Note: The dumps_* functions of this class already directly create - the invariant parts of the resulting json-object themselves, - without using the given json-encoder-function. + :see: JSON-RPC 2.0 specification + :todo: catch simplejson.dumps not-serializable-exceptions + :todo: rewrite serializer as modern java encoder subclass? support for more types this way? + """ + def __init__(self, dumps=json.dumps, loads=json.loads): + """ + init: set serializer to use + + :param dumps: json-encoder-function + :param loads: json-decoder-function + :note: The dumps_* functions of this class already directly create the invariant parts of the resulting + json-object themselves, without using the given json-encoder-function. """ self.dumps = dumps self.loads = loads - def dumps_request( self, method, params=(), id=0 ): - """serialize JSON-RPC-Request - - :Parameters: - - method: the method-name (str/unicode) - - params: the parameters (list/tuple/dict) - - id: the id (should not be None) - :Returns: | {"jsonrpc": "2.0", "method": "...", "params": ..., "id": ...} - | "jsonrpc", "method", "params" and "id" are always in this order. - | "params" is omitted if empty - :Raises: TypeError if method/params is of wrong type or - not JSON-serializable + def dumps_request(self, method, params=(), id=0): """ - if sys.version_info > (3,0): - if not isinstance(method, (str)): - raise TypeError('"method" must be a string (or unicode string).') - else: - if not isinstance(method, (str, unicode)): - raise TypeError('"method" must be a string (or unicode string).') - if not isinstance(params, (tuple, list, dict)): - raise TypeError("params must be a tuple/list/dict or None.") + serialize a JSON-RPC-Request to string - if params: - return '{"jsonrpc": "2.0", "method": %s, "params": %s, "id": %s}' % \ - (self.dumps(method), self.dumps(params), self.dumps(id)) - else: - return '{"jsonrpc": "2.0", "method": %s, "id": %s}' % \ - (self.dumps(method), self.dumps(id)) - - def dumps_notification( self, method, params=() ): - """serialize a JSON-RPC-Notification - - :Parameters: see dumps_request - :Returns: | {"jsonrpc": "2.0", "method": "...", "params": ...} - | "jsonrpc", "method" and "params" are always in this order. - :Raises: see dumps_request + :param method: name of the method to call + :type methods: str + :param params: data structure of args + :type params: dict,list,tuple + : type id: request id (should not be None) + :returns: string like: `{"jsonrpc": "2.0", "method": "...", "params": ..., "id": ...}`. "jsonrpc", "method", + "params" and "id" are always in this order. "params" is omitted if empty + :raises: TypeError if method/params is of wrong type or not JSON-serializable """ - if not isinstance(method, (str, unicode)): + if not isinstance(method, (str)): raise TypeError('"method" must be a string (or unicode string).') if not isinstance(params, (tuple, list, dict)): raise TypeError("params must be a tuple/list/dict or None.") if params: - return '{"jsonrpc": "2.0", "method": %s, "params": %s}' % \ - (self.dumps(method), self.dumps(params)) + return '{{"jsonrpc": "2.0", "method": {}, "params": {}, "id": {}}}' \ + .format(self.dumps(method), self.dumps(params), self.dumps(id)) else: - return '{"jsonrpc": "2.0", "method": %s}' % \ - (self.dumps(method)) + return '{{"jsonrpc": "2.0", "method": {}, "id": {}}}'.format(self.dumps(method), self.dumps(id)) - def dumps_response( self, result, id=None ): - """serialize a JSON-RPC-Response (without error) - - :Returns: | {"jsonrpc": "2.0", "result": ..., "id": ...} - | "jsonrpc", "result", and "id" are always in this order. - :Raises: TypeError if not JSON-serializable + def dumps_notification(self, method, params=()): """ - return '{"jsonrpc": "2.0", "result": %s, "id": %s}' % \ - (self.dumps(result), self.dumps(id)) + serialize a JSON-RPC-Notification - def dumps_error( self, error, id=None ): - """serialize a JSON-RPC-Response-error - - :Parameters: - - error: a RPCFault instance - :Returns: | {"jsonrpc": "2.0", "error": {"code": error_code, "message": error_message, "data": error_data}, "id": ...} - | "jsonrpc", "result", "error" and "id" are always in this order, data is omitted if None. - :Raises: ValueError if error is not a RPCFault instance, - TypeError if not JSON-serializable + :param method: name of the method to call + :type methods: str + :param params: data structure of args + :type params: dict,list,tuple + :return: String like `{"jsonrpc": "2.0", "method": "...", "params": ...}`. "jsonrpc", "method" and "params" + are always in this order. + :raises: see dumps_request + """ + if not isinstance(method, str): + raise TypeError('"method" must be a string (or unicode string).') + if not isinstance(params, (tuple, list, dict)): + raise TypeError("params must be a tuple/list/dict or None.") + + if params: + return '{{"jsonrpc": "2.0", "method": {}, "params": {}}}'.format(self.dumps(method), self.dumps(params)) + else: + return '{{"jsonrpc": "2.0", "method": {}}}'.format(self.dumps(method)) + + def dumps_response(self, result, id=None): + """ + serialize a JSON-RPC-Response (without error) + + :returns: str like `{"jsonrpc": "2.0", "result": ..., "id": ...}`."jsonrpc", "result", and "id" are always in + this order. + :raises: TypeError if not JSON-serializable + """ + return '{{"jsonrpc": "2.0", "result": {}, "id": {}}}'.format(self.dumps(result), self.dumps(id)) + + def dumps_error(self, error, id=None): + """ + serialize a JSON-RPC-Response-error + + :param error: error to serialize + :type error: RPCFault + :return: str like `{"jsonrpc": "2.0", "error": {"code": code, "message": message, "data": data}, "id": ...}`. + "jsonrpc", "result", "error" and "id" are always in this order, data is omitted if None. + :raises: ValueError if error is not a RPCFault instance, + TypeError if not JSON-serializable """ if not isinstance(error, RPCFault): - raise ValueError("""error must be a RPCFault-instance.""") + raise ValueError("error must be a RPCFault-instance.") if error.error_data is None: - return '{"jsonrpc": "2.0", "error": {"code":%s, "message": %s}, "id": %s}' % \ - (self.dumps(error.error_code), self.dumps(error.error_message), self.dumps(id)) + return '{{"jsonrpc": "2.0", "error": {{"code": {}, "message": {}}}, "id": {}}}' \ + .format(self.dumps(error.error_code), self.dumps(error.error_message), self.dumps(id)) else: - return '{"jsonrpc": "2.0", "error": {"code":%s, "message": %s, "data": %s}, "id": %s}' % \ - (self.dumps(error.error_code), self.dumps(error.error_message), self.dumps(error.error_data), self.dumps(id)) + return '{{"jsonrpc": "2.0", "error": {{"code": {}, "message": {}, "data": {}}}, "id": {}}}' \ + .format(self.dumps(error.error_code), self.dumps(error.error_message), + self.dumps(error.error_data), self.dumps(id)) - def loads_request( self, string ): - """de-serialize a JSON-RPC Request/Notification + def loads_request(self, string): + """ + de-serialize a JSON-RPC Request or Notification - :Returns: | [method_name, params, id] or [method_name, params] - | params is a tuple/list or dict (with only str-keys) - | if id is missing, this is a Notification - :Raises: RPCParseError, RPCInvalidRPC, RPCInvalidMethodParams + :return: `[method_name, params, id] or [method_name, params]`. `params` is a tuple/list or dict (with only + str-keys). if id is missing, this is a Notification + :raises: RPCParseError, RPCInvalidRPC, RPCInvalidMethodParams """ try: data = self.loads(string) except ValueError as err: - raise RPCParseError("No valid JSON. (%s)" % str(err)) - if not isinstance(data, dict): raise RPCInvalidRPC("No valid RPC-package.") - if "jsonrpc" not in data: raise RPCInvalidRPC("""Invalid Response, "jsonrpc" missing.""") - if sys.version_info > (3,0): - if not isinstance(data["jsonrpc"], (str)): - raise RPCInvalidRPC("""Invalid Response, "jsonrpc" must be a string.""") - else: - if not isinstance(data["jsonrpc"], (str, unicode)): - raise RPCInvalidRPC("""Invalid Response, "jsonrpc" must be a string.""") - if data["jsonrpc"] != "2.0": raise RPCInvalidRPC("""Invalid jsonrpc version.""") - if "method" not in data: raise RPCInvalidRPC("""Invalid Request, "method" is missing.""") - if sys.version_info > (3,0): - if not isinstance(data["method"], (str)): - raise RPCInvalidRPC("""Invalid Request, "method" must be a string.""") - else: - if not isinstance(data["method"], (str, unicode)): - raise RPCInvalidRPC("""Invalid Request, "method" must be a string.""") - if "params" not in data: data["params"] = () - #convert params-keys from unicode to str - elif isinstance(data["params"], dict): - try: - data["params"] = dictkeyclean(data["params"]) - except UnicodeEncodeError: - raise RPCInvalidMethodParams("Parameter-names must be in ascii.") + raise RPCParseError("No valid JSON. ({})".format(err)) + if not isinstance(data, dict): + raise RPCInvalidRPC("No valid RPC-package.") + if "jsonrpc" not in data: + raise RPCInvalidRPC('Invalid Response, "jsonrpc" missing.') + if not isinstance(data["jsonrpc"], str): + raise RPCInvalidRPC('Invalid Response, "jsonrpc" must be a string.') + if data["jsonrpc"] != "2.0": + raise RPCInvalidRPC("Invalid jsonrpc version.") + if "method" not in data: + raise RPCInvalidRPC('Invalid Request, "method" is missing.') + if not isinstance(data["method"], str): + raise RPCInvalidRPC('Invalid Request, "method" must be a string.') + if "params" not in data: + data["params"] = () elif not isinstance(data["params"], (list, tuple)): - raise RPCInvalidRPC("""Invalid Request, "params" must be an array or object.""") - if not( len(data)==3 or ("id" in data and len(data)==4) ): - raise RPCInvalidRPC("""Invalid Request, additional fields found.""") - + raise RPCInvalidRPC('Invalid Request, "params" must be an array or object.') + if not(len(data) == 3 or ("id" in data and len(data) == 4)): + raise RPCInvalidRPC('Invalid Request, additional fields found.') # notification / request - if "id" not in data: - return data["method"], data["params"] #notification + if "id" in data: + return data["method"], data["params"], data["id"] # request else: - return data["method"], data["params"], data["id"] #request + return data["method"], data["params"] # notification - def loads_response( self, string ): - """de-serialize a JSON-RPC Response/error + def loads_response(self, string): + """ + de-serialize a JSON-RPC Response/error - :Returns: | [result, id] for Responses - :Raises: | RPCFault+derivates for error-packages/faults, RPCParseError, RPCInvalidRPC + :return: [result, id] for Responses + :raises: RPCFault+derivates for error-packages/faults, RPCParseError, RPCInvalidRPC """ try: - if sys.version_info >= (3,0): - data = self.loads(string.decode("UTF-8")) - else: - data = self.loads(string) + data = self.loads(string) except ValueError as err: - raise RPCParseError("No valid JSON. (%s)" % str(err)) - if not isinstance(data, dict): raise RPCInvalidRPC("No valid RPC-package.") - if "jsonrpc" not in data: raise RPCInvalidRPC("""Invalid Response, "jsonrpc" missing.""") - if sys.version_info >= (3,0): - if not isinstance(data["jsonrpc"], (str)): - raise RPCInvalidRPC("""Invalid Response, "jsonrpc" must be a string.""") - else: - if not isinstance(data["jsonrpc"], (str, unicode)): - raise RPCInvalidRPC("""Invalid Response, "jsonrpc" must be a string.""") - if data["jsonrpc"] != "2.0": raise RPCInvalidRPC("""Invalid jsonrpc version.""") - if "id" not in data: raise RPCInvalidRPC("""Invalid Response, "id" missing.""") - if "result" not in data: data["result"] = None - if "error" not in data: data["error"] = None - if len(data) != 4: raise RPCInvalidRPC("""Invalid Response, additional or missing fields.""") - - #error - if data["error"] is not None: + raise RPCParseError("No valid JSON. ({})".format(err)) + if not isinstance(data, dict): + raise RPCInvalidRPC("No valid RPC-package.") + if "jsonrpc" not in data: + raise RPCInvalidRPC('Invalid Response, "jsonrpc" missing.') + if not isinstance(data["jsonrpc"], (str)): + raise RPCInvalidRPC('Invalid Response, "jsonrpc" must be a string.') + if data["jsonrpc"] != "2.0": + raise RPCInvalidRPC('Invalid jsonrpc version.') + if "id" not in data: + raise RPCInvalidRPC('Invalid Response, "id" missing.') + if "result" not in data: + data["result"] = None + if "error" not in data: + data["error"] = None + if len(data) != 4: + raise RPCInvalidRPC("Invalid Response, additional or missing fields.") + if data["error"] is not None: # handle remote error case if data["result"] is not None: - raise RPCInvalidRPC("""Invalid Response, only "result" OR "error" allowed.""") - if not isinstance(data["error"], dict): raise RPCInvalidRPC("Invalid Response, invalid error-object.") - if "code" not in data["error"] or "message" not in data["error"]: + raise RPCInvalidRPC('Invalid Response, only "result" OR "error" allowed.') + if not isinstance(data["error"], dict): raise RPCInvalidRPC("Invalid Response, invalid error-object.") - if "data" not in data["error"]: data["error"]["data"] = None + if "code" not in data["error"] or "message" not in data["error"]: + raise RPCInvalidRPC("Invalid Response, invalid error-object.") + if "data" not in data["error"]: + data["error"]["data"] = None if len(data["error"]) != 3: raise RPCInvalidRPC("Invalid Response, invalid error-object.") - error_data = data["error"]["data"] - if data["error"]["code"] == PARSE_ERROR: - raise RPCParseError(error_data) - elif data["error"]["code"] == INVALID_REQUEST: - raise RPCInvalidRPC(error_data) - elif data["error"]["code"] == METHOD_NOT_FOUND: - raise RPCMethodNotFound(error_data) - elif data["error"]["code"] == INVALID_METHOD_PARAMS: - raise RPCInvalidMethodParams(error_data) - elif data["error"]["code"] == INTERNAL_ERROR: - raise RPCInternalError(error_data) - elif data["error"]["code"] == PROCEDURE_EXCEPTION: - raise RPCProcedureException(error_data) - elif data["error"]["code"] == AUTHENTIFICATION_ERROR: - raise RPCAuthentificationError(error_data) - elif data["error"]["code"] == PERMISSION_DENIED: - raise RPCPermissionDenied(error_data) - elif data["error"]["code"] == INVALID_PARAM_VALUES: - raise RPCInvalidParamValues(error_data) + error_code = data["error"]["code"] + if error_code in ERROR_CODE_EXCEPTIONS: + raise ERROR_CODE_EXCEPTIONS[error_code](error_data) else: raise RPCFault(data["error"]["code"], data["error"]["message"], error_data) - #result - else: + else: # successful call, return result return data["result"], data["id"] -#========================================= -# transports - -#---------------------- -# transport-logging - -import codecs -import time - -def log_dummy( message ): - """dummy-logger: do nothing""" +def log_dummy(message): pass -def log_stdout( message ): - """print message to STDOUT""" + + +def log_stdout(message): + """ + print message to STDOUT + """ print(message) -def log_file( filename ): + +def log_file(filename): """return a logfunc which logs to a file (in utf-8)""" - def logfile( message ): - f = codecs.open( filename, 'a', encoding='utf-8' ) - f.write( message+"\n" ) + def logfile(message): + f = codecs.open(filename, 'a') + f.write(message + "\n") f.close() return logfile -def log_filedate( filename ): + +def log_filedate(filename): """return a logfunc which logs date+message to a file (in utf-8)""" - def logfile( message ): - f = codecs.open( filename, 'a', encoding='utf-8' ) - f.write( time.strftime("%Y-%m-%d %H:%M:%S ")+message+"\n" ) + def logfile(message): + f = codecs.open(filename, 'a') + f.write("{} {}\n".format(time.strftime("%Y-%m-%d %H:%M:%S"), message)) f.close() return logfile -#---------------------- -class Transport: - """generic Transport-interface. - - This class, and especially its methods and docstrings, - define the Transport-Interface. +class Transport(object): + """ + generic Transport-interface. + + This class, and especially its methods and docstrings, define the Transport-Interface. """ def __init__(self): pass - def send( self, data ): - """send all data. must be implemented by derived classes.""" - raise NotImplementedError - def recv( self ): - """receive data. must be implemented by derived classes.""" + def send(self, data): + """ + send all data. must be implemented by derived classes. + :param data: data to send + :type data: str + """ raise NotImplementedError - def sendrecv( self, string ): - """send + receive data""" - self.send( string ) + def recv(self): + """ + receive data. must be implemented by derived classes. + :return str: + """ + raise NotImplementedError + + def sendrecv(self, string): + """ + send + receive data + :param string: message to send + :type string: str + """ + self.send(string) return self.recv() - def serve( self, handler, n=None ): - """serve (forever or for n communicaions). - + + def serve(self, handler, n=None): + """ + serve (forever or for n communicaions). + - receive data - call result = handler(data) - send back result if not None @@ -730,129 +690,140 @@ class Transport: """ n_current = 0 while 1: - if n is not None and n_current >= n: + if n is not None and n_current >= n: break data = self.recv() result = handler(data) if result is not None: - self.send( result ) + self.send(result) n_current += 1 class TransportSTDINOUT(Transport): - """receive from STDIN, send to STDOUT. - - Useful e.g. for debugging. + """ + receive from STDIN, send to STDOUT. Useful e.g. for debugging. """ def send(self, string): - """write data to STDOUT with '\*\*\*SEND:' prefix """ + """ + write data to STDOUT with '\*\*\*SEND:' prefix + """ print("***SEND:") print(string) + def recv(self): """read data from STDIN""" print("***RECV (please enter, ^D ends.):") return sys.stdin.read() -import socket, select class TransportSocket(Transport): - """Transport via socket. - - :SeeAlso: python-module socket + """ + Transport via socket. + :TODO: - documentation - improve this (e.g. make sure that connections are closed, socket-files are deleted etc.) - exception-handling? (socket.error) """ - def __init__( self, addr, limit=4096, sock_type=socket.AF_INET, sock_prot=socket.SOCK_STREAM, timeout=1.0, logfunc=log_dummy ): + def __init__(self, addr, limit=4096, + sock_type=socket.AF_INET, sock_prot=socket.SOCK_STREAM, timeout=1.0, + logfunc=log_dummy): """ - :Parameters: - - addr: socket-address - - timeout: timeout in seconds - - logfunc: function for logging, logfunc(message) + :param addr: socket-address + :param timeout: connect timeout in seconds + :param logfunc: function for logging, logfunc(message) :Raises: socket.timeout after timeout """ - self.limit = limit - self.addr = addr + self.limit = limit + self.addr = addr self.s_type = sock_type self.s_prot = sock_prot - self.s = None + self.s = None self.timeout = timeout - self.log = logfunc - self._send = self._send_2x; - if sys.version_info > (3,0): - self._send = self._send_3x; - - def _send_3x(self, conn, result): - conn.send( bytes(result, 'utf-8') ) - def _send_2x(self, conn, result): - conn.send( result ) - - def connect( self ): + self.log = logfunc + + def _send(self, conn, result): + """ + Send a result to the given connection + :param conn: + :param result: text result to send + :type result str: + """ + conn.send(result.encode("UTF-8")) + + def connect(self): self.close() - self.log( "connect to %s" % repr(self.addr) ) - self.s = socket.socket( self.s_type, self.s_prot ) - self.s.settimeout( self.timeout ) + self.log("connect to %s" % repr(self.addr)) + self.s = socket.socket(self.s_type, self.s_prot) + self.s.settimeout(self.timeout) self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.s.connect( self.addr ) - def close( self ): - if self.s is not None: - self.log( "close %s" % repr(self.addr) ) + self.s.connect(self.addr) + + def close(self): + if self.s: + self.log("close %s" % repr(self.addr)) self.s.close() self.s = None + def __repr__(self): return "" % repr(self.addr) - - def send( self, string ): - if self.s is None: + + def send(self, string): + if not self.s: self.connect() - self.log( "--> "+repr(string) ) - self.s.sendall( string ) - def recv( self ): - if self.s is None: + self.log("--> {}".format(repr(string))) + self.s.sendall(string.encode("UTF-8")) + + def recv(self): + if not self.s: self.connect() - data = self.s.recv( self.limit ) - while( select.select((self.s,), (), (), 0.1)[0] ): #TODO: this select is probably not necessary, because server closes this socket - d = self.s.recv( self.limit ) + data = self.s.recv(self.limit) + #TODO: this select is probably not necessary, because server closes this socket + while select.select((self.s,), (), (), 0.1)[0]: + d = self.s.recv(self.limit) if len(d) == 0: break data += d - self.log( "<-- "+repr(data) ) - return data + self.log("<-- {}".format(repr(data))) + return data.decode("UTF-8") - def sendrecv( self, string ): + def sendrecv(self, string): """send data + receive data + close""" try: - self.send( string ) + self.send(string) return self.recv() finally: self.close() + def serve(self, handler, n=None): """open socket, wait for incoming connections and handle them. - + :Parameters: - n: serve n requests, None=forever """ self.close() - self.s = socket.socket( self.s_type, self.s_prot ) + self.s = socket.socket(self.s_type, self.s_prot) self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: - self.log( "listen %s" % repr(self.addr) ) - self.s.bind( self.addr ) + self.log("listen {}".format(repr(self.addr))) + self.s.bind(self.addr) self.s.listen(1) n_current = 0 while 1: - if n is not None and n_current >= n: + if n is not None and n_current >= n: break - conn, addr = self.s.accept() - self.log( "%s connected" % repr(addr) ) + try: + conn, addr = self.s.accept() + except OSError: # Socket likely shut down + break + self.log("%s connected" % repr(addr)) data = conn.recv(self.limit) - self.log( "%s --> %s" % (repr(addr), repr(data)) ) + self.log("%s --> %s" % (repr(addr), repr(data))) result = handler(data) - if data is not None: - self.log( "%s <-- %s" % (repr(addr), repr(result)) ) + if data: + self.log("%s <-- %s" % (repr(addr), repr(result))) self._send(conn, result) - self.log( "%s close" % repr(addr) ) + self.log("%s close" % repr(addr)) conn.close() n_current += 1 finally: @@ -860,55 +831,50 @@ class TransportSocket(Transport): if hasattr(socket, 'AF_UNIX'): - class TransportUnixSocket(TransportSocket): - """Transport via Unix Domain Socket. + """ + Transport via Unix Domain Socket. """ def __init__(self, addr=None, limit=4096, timeout=1.0, logfunc=log_dummy): """ - :Parameters: - - addr: "socket_file" - :Note: | The socket-file is not deleted. - | If the socket-file begins with \x00, abstract sockets are used, - and no socket-file is created. - :SeeAlso: TransportSocket + :param addr: path to socket file + :type addr: str + :note: The socket-file is not deleted. If the socket-file begins with \x00, abstract sockets are used, + and no socket-file is created. + :see: TransportSocket """ - TransportSocket.__init__( self, addr, limit, socket.AF_UNIX, socket.SOCK_STREAM, timeout, logfunc ) + TransportSocket.__init__(self, addr, limit, socket.AF_UNIX, socket.SOCK_STREAM, timeout, logfunc) + class TransportTcpIp(TransportSocket): - """Transport via TCP/IP. + """ + Transport via TCP/IP. """ def __init__(self, addr=None, limit=4096, timeout=1.0, logfunc=log_dummy): """ - :Parameters: - - addr: ("host",port) - :SeeAlso: TransportSocket + :param addr: ("host", port) + :type param: tuple + :see: TransportSocket """ - TransportSocket.__init__( self, addr, limit, socket.AF_INET, socket.SOCK_STREAM, timeout, logfunc ) + TransportSocket.__init__(self, addr, limit, socket.AF_INET, socket.SOCK_STREAM, timeout, logfunc) -#========================================= -# client side: server proxy - -class ServerProxy: +class ServerProxy(object): """RPC-client: server proxy - A logical connection to a RPC server. + A client-side logical connection to a RPC server. It works with different data/serializers and different transports. Notifications and id-handling/multicall are not yet implemented. - :Example: - see module-docstring - - :TODO: verbose/logging? + :example: see module-docstring + :todo: verbose/logging? """ - def __init__( self, data_serializer, transport ): + def __init__(self, data_serializer, transport): """ - :Parameters: - - data_serializer: a data_structure+serializer-instance - - transport: a Transport instance + :param data_serializer: a data_structure+serializer-instance + :param transport: a Transport instance """ #TODO: check parameters self.__data_serializer = data_serializer @@ -918,29 +884,26 @@ class ServerProxy: def __str__(self): return repr(self) + def __repr__(self): return "" % (self.__transport, self.__data_serializer) - def __req( self, methodname, args=None, kwargs=None, id=0 ): + def __req(self, methodname, args=None, kwargs=None, id=0): # JSON-RPC 1.0: only positional parameters - if len(kwargs) > 0 and isinstance(self.data_serializer, JsonRpc10): + if kwargs and isinstance(self.data_serializer, JsonRpc10): raise ValueError("Only positional parameters allowed in JSON-RPC 1.0") # JSON-RPC 2.0: only args OR kwargs allowed! - if len(args) > 0 and len(kwargs) > 0: + if args and kwargs: raise ValueError("Only positional or named parameters are allowed!") - if len(kwargs) == 0: - req_str = self.__data_serializer.dumps_request( methodname, args, id ) - else: - req_str = self.__data_serializer.dumps_request( methodname, kwargs, id ) + req_str = self.__data_serializer.dumps_request(methodname, + args if isinstance(self.data_serializer, JsonRpc10) else args, + id) try: - if sys.version_info > (3,0): - resp_str = self.__transport.sendrecv( bytes(req_str, 'utf-8') ) - else: - resp_str = self.__transport.sendrecv( req_str ) + resp_str = self.__transport.sendrecv(req_str) except Exception as err: raise RPCTransportError(err) - resp = self.__data_serializer.loads_response( resp_str ) + resp = self.__data_serializer.loads_response(resp_str) return resp[0] def __getattr__(self, name): @@ -949,33 +912,35 @@ class ServerProxy: # result getattr(my_server_proxy, "strange-python-name")(args) return _method(self.__req, name) -# request dispatcher -class _method: - """some "magic" to bind an RPC method to an RPC server. + +class _method(object): + """ + Some "magic" to bind an RPC method to an RPC server. A request dispatcher. Supports "nested" methods (e.g. examples.getStateName). - :Raises: AttributeError for method-names/attributes beginning with '_'. + :raises: AttributeError for method-names/attributes beginning with '_'. """ def __init__(self, req, name): - if name[0] == "_": #prevent rpc-calls for proxy._*-functions + if name.startswith("_"): # prevent rpc-calls for proxy._*-functions raise AttributeError("invalid attribute '%s'" % name) - self.__req = req + self.__req = req self.__name = name + def __getattr__(self, name): - if name[0] == "_": #prevent rpc-calls for proxy._*-functions + if name.startswith("_"): # prevent rpc-calls for proxy._*-functions raise AttributeError("invalid attribute '%s'" % name) return _method(self.__req, "%s.%s" % (self.__name, name)) + def __call__(self, *args, **kwargs): return self.__req(self.__name, args, kwargs) -#========================================= -# server side: Server -class Server: - """RPC-server. +class Server(object): + """ + RPC server. - It works with different data/serializers and + It works with different data/serializers and with different transports. :Example: @@ -985,129 +950,127 @@ class Server: - mixed JSON-RPC 1.0/2.0 server? - logging/loglevels? """ - def __init__( self, data_serializer, transport, logfile=None ): + def __init__(self, data_serializer, transport, logfile=None): """ - :Parameters: - - data_serializer: a data_structure+serializer-instance - - transport: a Transport instance - - logfile: file to log ("unexpected") errors to + :param data_serializer: a data_structure+serializer-instance + :param transport: a Transport instance + :param logfile: file to log ("unexpected") errors to """ - #TODO: check parameters + #TODO: check all parameters self.__data_serializer = data_serializer if not isinstance(transport, Transport): raise ValueError('invalid "transport" (must be a Transport-instance)"') self.__transport = transport self.logfile = logfile - if self.logfile is not None: #create logfile (or raise exception) - f = codecs.open( self.logfile, 'a', encoding='utf-8' ) - f.close() - + if self.logfile: + with open(self.logfile, 'a'): # create logfile (or raise exception) + pass self.funcs = {} def __repr__(self): return "" % (self.__transport, self.__data_serializer) def log(self, message): - """write a message to the logfile (in utf-8)""" - if self.logfile is not None: - f = codecs.open( self.logfile, 'a', encoding='utf-8' ) - f.write( time.strftime("%Y-%m-%d %H:%M:%S ")+message+"\n" ) - f.close() + """ + write a message to the logfile + :param message: log message to write + :type message: str + """ + if self.logfile: + #TODO don't reopen the log every time + with open(self.logfile, 'a') as f: + f.write("{} {}\n".format(time.strftime("%Y-%m-%d %H:%M:%S"), message)) def register_instance(self, myinst, name=None): - """Add all functions of a class-instance to the RPC-services. - + """ + Add all functions of a class-instance to the RPC-services. + All entries of the instance which do not begin with '_' are added. - :Parameters: - - myinst: class-instance containing the functions - - name: | hierarchical prefix. - | If omitted, the functions are added directly. - | If given, the functions are added as "name.function". - :TODO: + :param myinst: class-instance containing the functions + :param name: hierarchical prefix. If omitted, the functions are added directly. If given, the functions are + added as "name.function". + :todo: - only add functions and omit attributes? - improve hierarchy? """ - for e in dir(myinst): - if e[0][0] != "_": - if name is None: - self.register_function( getattr(myinst, e) ) - else: - self.register_function( getattr(myinst, e), name="%s.%s" % (name, e) ) - def register_function(self, function, name=None): - """Add a function to the RPC-services. - - :Parameters: - - function: function to add - - name: RPC-name for the function. If omitted/None, the original - name of the function is used. - """ - if name is None: - self.funcs[function.__name__] = function - else: - self.funcs[name] = function - - def handle(self, rpcstr): - """Handle a RPC-Request. + for attr in dir(myinst): + if attr.startswith("_"): + continue + if name: + self.register_function(getattr(myinst, attr), name="{}.{}".format(name, attr)) + else: + self.register_function(getattr(myinst, attr)) - :Parameters: - - rpcstr: the received rpc-string + def register_function(self, function, name=None): + """ + Add a function to the RPC-services. + + :param function: callable to add + :param name: RPC-name for the function. If omitted/None, the original name of the function is used. + :type name: str + """ + self.funcs[name or function.__name__] = function + + def handle(self, rpcstr): + """ + Handle a RPC Request. + + :param rpcstr: the received rpc message + :type rpcstr: str :Returns: the data to send back or None if nothing should be sent back :Raises: RPCFault (and maybe others) """ #TODO: id notification = False try: - req = None - if sys.version_info > (3,0): - req = self.__data_serializer.loads_request( rpcstr.decode("UTF-8") ) - else: - req = self.__data_serializer.loads_request( rpcstr ) - if len(req) == 2: #notification + req = self.__data_serializer.loads_request(rpcstr) + if len(req) == 2: # notification method, params = req notification = True - else: #request + else: # request method, params, id = req except RPCFault as err: - return self.__data_serializer.dumps_error( err, id=None ) + return self.__data_serializer.dumps_error(err, id=None) except Exception as err: - self.log( "%d (%s): %s" % (INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR], str(err)) ) - return self.__data_serializer.dumps_error( RPCFault(INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR]), id=None ) + self.log("%d (%s): %s" % (INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR], str(err))) + return self.__data_serializer.dumps_error(RPCFault(INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR]), id=None) if method not in self.funcs: if notification: return None - return self.__data_serializer.dumps_error( RPCFault(METHOD_NOT_FOUND, ERROR_MESSAGE[METHOD_NOT_FOUND]), id ) + return self.__data_serializer.dumps_error(RPCFault(METHOD_NOT_FOUND, ERROR_MESSAGE[METHOD_NOT_FOUND]), id) try: if isinstance(params, dict): - result = self.funcs[method]( **params ) + result = self.funcs[method](**params) else: - result = self.funcs[method]( *params ) + result = self.funcs[method](*params) except RPCFault as err: if notification: return None - return self.__data_serializer.dumps_error( err, id=None ) + return self.__data_serializer.dumps_error(err, id=None) except Exception as err: logging.getLogger('RPCLib').error("Error executing RPC: %s" % str(err)) if notification: return None - self.log( "%d (%s): %s" % (INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR], str(err)) ) - return self.__data_serializer.dumps_error( RPCFault(INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR], str(err)), id ) + self.log("%d (%s): %s" % (INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR], str(err))) + return self.__data_serializer.dumps_error( + RPCFault(INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR], str(err)), id) + if notification: return None + try: - return self.__data_serializer.dumps_response( result, id ) + return self.__data_serializer.dumps_response(result, id) except Exception as err: - self.log( "%d (%s): %s" % (INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR], str(err)) ) - return self.__data_serializer.dumps_error( RPCFault(INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR]), id ) + self.log("%d (%s): %s" % (INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR], str(err))) + return self.__data_serializer.dumps_error(RPCFault(INTERNAL_ERROR, ERROR_MESSAGE[INTERNAL_ERROR]), id) def serve(self, n=None): - """serve (forever or for n communicaions). - - :See: Transport """ - self.__transport.serve( self.handle, n ) - -#========================================= + Run the server (forever or for n communicaions). + :see: Transport + """ + self.__transport.serve(self.handle, n) diff --git a/pyircbot/rpc.py b/pyircbot/rpc.py index 5a1cb14..4bd4072 100644 --- a/pyircbot/rpc.py +++ b/pyircbot/rpc.py @@ -6,7 +6,6 @@ """ -import traceback import logging from pyircbot import jsonrpc from threading import Thread diff --git a/tests/modules/calc.py b/tests/modules/calc.py deleted file mode 100644 index 1813f6a..0000000 --- a/tests/modules/calc.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest -from pyircbot.modules.Calc import Calc -from pyircbot.pyircbot import ModuleLoader - - -class FakeBaseBot(ModuleLoader): - - " IRC methods " - def act_PRIVMSG(self, towho, message): - """Use the `/msg` command - - :param towho: the target #channel or user's name - :type towho: str - :param message: the message to send - :type message: str""" - # self.sendRaw("PRIVMSG %s :%s" % (towho, message)) - print("act_PRIVMSG(towho={}, message={})".format(towho, message)) - - -@pytest.fixture -def fakebot(): - bot = FakeBaseBot() - bot.botconfig = {"bot": {"datadir": "./examples/data/"}} - bot.loadmodule("SQLite") - bot.loadmodule("Calc") - return bot - - -def test_foo(fakebot): - print(fakebot) diff --git a/tests/test_jsonrpc.py b/tests/test_jsonrpc.py new file mode 100644 index 0000000..af47994 --- /dev/null +++ b/tests/test_jsonrpc.py @@ -0,0 +1,221 @@ +import os +import pytest +from pyircbot import jsonrpc +from threading import Thread +from random import randint +from socket import SHUT_RDWR +from time import sleep + + +# Sample server methods + +def sample(value): + return value + + +class _sample(object): + def sample(self, value): + return value + + +def client(port, v=2): + return jsonrpc.ServerProxy((jsonrpc.JsonRpc20 if v == 2 else jsonrpc.JsonRpc10)(), + jsonrpc.TransportTcpIp(addr=("127.0.0.1", port), timeout=2.0)) + + +# Fixures for each server version provide a (server_instance, port) tuple. +# Each have the method "sample", which returns the value passed +# Each have a class instance registered as "obj", which the method "sample" as well + +@pytest.fixture +def j1testserver(): + port = randint(40000, 60000) + server = jsonrpc.Server(jsonrpc.JsonRpc10(), + jsonrpc.TransportTcpIp(addr=("127.0.0.1", port))) + server.register_function(sample) + server.register_instance(_sample(), name="obj") + Thread(target=server.serve, daemon=True).start() + sleep(0.1) # Give the serve() time to set up the serversocket + yield (server, port) + server._Server__transport.s.shutdown(SHUT_RDWR) + + +@pytest.fixture +def j2testserver(): + port = randint(40000, 60000) + server = jsonrpc.Server(jsonrpc.JsonRpc20(), + jsonrpc.TransportTcpIp(addr=("127.0.0.1", port))) + server.register_function(sample) + server.register_instance(_sample(), name="obj") + Thread(target=server.serve, daemon=True).start() + sleep(0.1) # Give the serve() time to set up the serversocket + yield (server, port) + server._Server__transport.s.shutdown(SHUT_RDWR) + + +# Basic functionality +def test_1_basic(j1testserver): + str(jsonrpc.RPCFault(-32700, "foo", "bar")) + server, port = j1testserver + str(client(port, v=1)) + ret = client(port, v=1).sample("foobar") + assert ret == "foobar" + + +def test_2_basic(j2testserver): + server, port = j2testserver + str(client(port)) + ret = client(port).sample("foobar") + assert ret == "foobar" + + +def test_1_instance(j1testserver): + server, port = j1testserver + ret = client(port, v=1).obj.sample("foobar") + assert ret == "foobar" + + +def test_2_instance(j2testserver): + server, port = j2testserver + ret = client(port).obj.sample("foobar") + assert ret == "foobar" + + +# Missing methods raise clean error +def test_1_notfound(j1testserver): + server, port = j1testserver + with pytest.raises(jsonrpc.RPCMethodNotFound): + client(port, v=1).idontexist("f") + with pytest.raises(jsonrpc.RPCMethodNotFound): + client(port, v=1).neither.idontexist("f") + + +def test_2_notfound(j2testserver): + server, port = j2testserver + with pytest.raises(jsonrpc.RPCMethodNotFound): + client(port).idontexist("f") + with pytest.raises(jsonrpc.RPCMethodNotFound): + client(port).neither.idontexist("f") + + +# Underscore methods are blocked +def test_1_underscore(): + with pytest.raises(AttributeError): + client(-1)._notallowed() + + +def test_2_underscore(): + with pytest.raises(AttributeError): + client(-1)._notallowed() + + +# Response parsing hardness +def _test_1_protocol_parse_base(method): + with pytest.raises(jsonrpc.RPCParseError): # Not json + method("") + with pytest.raises(jsonrpc.RPCInvalidRPC): # Not a dict + method("[]") + with pytest.raises(jsonrpc.RPCInvalidRPC): # Missing 'id' + method("{}") + with pytest.raises(jsonrpc.RPCInvalidRPC): # not 3 fields + method('{"id": 0, "baz": 0}') + + +def _test_2_protocol_parse_base(method): + with pytest.raises(jsonrpc.RPCParseError): # Not json + method("") + with pytest.raises(jsonrpc.RPCInvalidRPC): # Not a dict + method("[]") + with pytest.raises(jsonrpc.RPCInvalidRPC): # missing jsonrpc + method('{}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # jsonrpc must be str + method('{"jsonrpc": 1}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # jsonrpc must be "2.0" + method('{"jsonrpc": "2.1"}') + + +def test_1_invalid_response(): + j = jsonrpc.JsonRpc10() + _test_1_protocol_parse_base(j.loads_response) + with pytest.raises(jsonrpc.RPCInvalidRPC): # can't have result and error + j.loads_response('{"id": 0, "result": 1, "error": 0}') + + +def test_2_invalid_response(): + j = jsonrpc.JsonRpc20() + _test_2_protocol_parse_base(j.loads_response) + with pytest.raises(jsonrpc.RPCInvalidRPC): # Missing 'id' + j.loads_response('{"jsonrpc": "2.0"}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # not 4 fields + j.loads_response('{"id": 0, "jsonrpc": "2.0", "bar": 1}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # can't have result and error + j.loads_response('{"id": 0, "jsonrpc": "2.0", "result": 1, "error": 0}') + + +# Request parsing hardness +def test_1_invalid_request(): + j = jsonrpc.JsonRpc10() + _test_1_protocol_parse_base(j.loads_request) + + with pytest.raises(jsonrpc.RPCInvalidRPC): # missing method + j.loads_request('{"id": 0}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # method must be str + j.loads_request('{"id": 0, "method": -1}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # params is bad type + j.loads_request('{"id": 0, "method": "foo", "params": -1}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # wrong number of fields + j.loads_request('{"ba": 0, "method": "foo", "asdf": 1, "foobar": 2}') + j.loads_request('{"id": 0, "method": "foo", "params": []}') + j.loads_request('{"method": "foo", "params": []}') + + +# Request parsing hardness +def test_2_invalid_request(): + j = jsonrpc.JsonRpc20() + _test_2_protocol_parse_base(j.loads_request) + + with pytest.raises(jsonrpc.RPCInvalidRPC): # missing method + j.loads_request('{"id": 0, "jsonrpc": "2.0"}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # method must be str + j.loads_request('{"id": 0, "jsonrpc": "2.0", "method": 1}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # params is bad type + j.loads_request('{"id": 0, "jsonrpc": "2.0", "method": "foo", "params": -1}') + with pytest.raises(jsonrpc.RPCInvalidRPC): # wrong number of fields + j.loads_request('{"id": 0, "jsonrpc": "2.0", "method": "foo", "asdf": 1, "foobar": 2}') + j.loads_request('{"id": 0, "jsonrpc": "2.0", "method": "foo", "params": []}') + j.loads_request('{"jsonrpc": "2.0", "method": "foo", "params": []}') + + +def test_1_dumps_reqest(): + j = jsonrpc.JsonRpc20() + with pytest.raises(TypeError): + j.dumps_request(-1) + with pytest.raises(TypeError): + j.dumps_request("foo", params=-1) + j.dumps_request("foo") + + +def test_2_dumps_reqest(): + j = jsonrpc.JsonRpc20() + with pytest.raises(TypeError): + j.dumps_request(-1) + with pytest.raises(TypeError): + j.dumps_request("foo", params=-1) + j.dumps_request("foo", params=[]) + j.dumps_request("foo") + + +# Misc stuff +def test_logging(tmpdir): + msg = "test log message" + jsonrpc.log_dummy(msg) + jsonrpc.log_stdout(msg) + logpath = os.path.join(tmpdir, "test.log") + logger = jsonrpc.log_file(logpath) + logger(msg) + assert os.path.exists(logpath) + + logpath = os.path.join(tmpdir, "test2.log") + logger2 = jsonrpc.log_filedate(os.path.join(tmpdir, "test2.log")) + logger2(msg) + assert os.path.exists(logpath)