diff --git a/docs/changes.rst b/docs/changes.rst index 64fe1ad6..6fca3cbf 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,19 @@ Changes This change log is used to track all major changes to Mopidy. +v0.10.0 (in development) +======================== + +**Changes** + +- None yet + +**Bug fixes** + +- :issue:`256`: Fix crash caused by non-ASCII characters in paths returned from + ``glib``. The bug can be worked around by overriding the settings that + includes offending `$XDG_` variables. + v0.9.0 (2012-11-21) =================== diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py new file mode 100644 index 00000000..23f50580 --- /dev/null +++ b/mopidy/utils/jsonrpc.py @@ -0,0 +1,393 @@ +from __future__ import unicode_literals + +import inspect +import json +import traceback + +import pykka + + +class JsonRpcWrapper(object): + """ + Wrap objects and make them accessible through JSON-RPC 2.0 messaging. + + This class takes responsibility of communicating with the objects and + processing of JSON-RPC 2.0 messages. The transport of the messages over + HTTP, WebSocket, TCP, or whatever is of no concern to this class. + + To expose a single object, add it to the objects mapping using the empty + string as the key:: + + jrw = JsonRpcWrapper(objects={'': my_object}) + + To expose multiple objects, add them all to the objects mapping. The key in + the mapping is used as the object's mounting point in the exposed API:: + + jrw = JsonRpcWrapper(objects={ + '': foo, + 'hello': lambda: 'Hello, world!', + 'abc': abc, + }) + + This will create the following mapping between JSON-RPC 2.0 method names + and Python callables:: + + bar -> foo.bar() + baz -> foo.baz() + hello -> lambda + abc.def -> abc.def() + + Only the public methods of the objects will be exposed. Attributes are not + exposed by themself, but public methods on public attributes are exposed, + using dotted paths from the exposed object to the method at the end of the + path. + + If a method returns a :class:`pykka.Future`, the future will be completed + and its value unwrapped before the JSON-RPC wrapper returns the response. + + For further details on the JSON-RPC 2.0 spec, see + http://www.jsonrpc.org/specification + + :param objects: mapping between mounting points and exposed functions or + class instances + :type objects: dict + :param decoders: object builders to be used by :func`json.loads` + :type decoders: list of functions taking a dict and returning a dict + :param encoders: object serializers to be used by :func:`json.dumps` + :type encoders: list of :class:`json.JSONEncoder` subclasses with the + method :meth:`default` implemented + """ + + def __init__(self, objects, decoders=None, encoders=None): + self.obj = self._build_exported_object(objects) + self.decoder = get_combined_json_decoder(decoders or []) + self.encoder = get_combined_json_encoder(encoders or []) + + def _build_exported_object(self, objects): + class EmptyObject(object): + pass + + if '' in objects: + exported_object = objects[''] + else: + exported_object = EmptyObject() + + mounts = sorted(objects.keys(), key=lambda x: len(x)) + for mount in mounts: + parent = exported_object + path = mount.split('.') + for part in path[:-1]: + if not hasattr(parent, part): + setattr(parent, part, EmptyObject()) + parent = getattr(parent, part) + if path[-1]: + setattr(parent, path[-1], objects[mount]) + + return exported_object + + def handle_json(self, request): + """ + Handles an incoming request encoded as a JSON string. + + Returns a response as a JSON string for commands, and :class:`None` for + notifications. + + :param request: the serialized JSON-RPC request + :type request: string + :rtype: string or :class:`None` + """ + try: + request = json.loads(request, object_hook=self.decoder) + except ValueError: + response = JsonRpcParseError().get_response() + else: + response = self.handle_data(request) + if response is None: + return None + return json.dumps(response, cls=self.encoder) + + def handle_data(self, request): + """ + Handles an incoming request in the form of a Python data structure. + + Returns a Python data structure for commands, or a :class:`None` for + notifications. + + :param request: the unserialized JSON-RPC request + :type request: dict + :rtype: dict, list, or :class:`None` + """ + if isinstance(request, list): + return self._handle_batch(request) + else: + return self._handle_single_request(request) + + def _handle_batch(self, requests): + if not requests: + return JsonRpcInvalidRequestError( + data='Batch list cannot be empty').get_response() + + responses = [] + for request in requests: + response = self._handle_single_request(request) + if response: + responses.append(response) + + return responses or None + + def _handle_single_request(self, request): + try: + self._validate_request(request) + args, kwargs = self._get_params(request) + except JsonRpcInvalidRequestError as error: + return error.get_response() + + try: + method = self._get_method(request['method']) + + try: + result = method(*args, **kwargs) + + if self._is_notification(request): + return None + + if self._is_future(result): + result = result.get() + + return { + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': result, + } + except TypeError as error: + raise JsonRpcInvalidParamsError(data={ + 'type': error.__class__.__name__, + 'message': unicode(error), + 'traceback': traceback.format_exc(), + }) + except Exception as error: + raise JsonRpcApplicationError(data={ + 'type': error.__class__.__name__, + 'message': unicode(error), + 'traceback': traceback.format_exc(), + }) + except JsonRpcError as error: + if self._is_notification(request): + return None + return error.get_response(request['id']) + + def _validate_request(self, request): + if not isinstance(request, dict): + raise JsonRpcInvalidRequestError( + data='Request must be an object') + if not 'jsonrpc' in request: + raise JsonRpcInvalidRequestError( + data='"jsonrpc" member must be included') + if request['jsonrpc'] != '2.0': + raise JsonRpcInvalidRequestError( + data='"jsonrpc" value must be "2.0"') + if not 'method' in request: + raise JsonRpcInvalidRequestError( + data='"method" member must be included') + if not isinstance(request['method'], unicode): + raise JsonRpcInvalidRequestError( + data='"method" must be a string') + + def _get_params(self, request): + if not 'params' in request: + return [], {} + params = request['params'] + if isinstance(params, list): + return params, {} + elif isinstance(params, dict): + return [], params + else: + raise JsonRpcInvalidRequestError( + data='"params", if given, must be an array or an object') + + def _get_method(self, name): + try: + path = name.split('.') + this = self.obj + for part in path: + if part.startswith('_'): + raise AttributeError + this = getattr(this, part) + return this + except (AttributeError, KeyError): + raise JsonRpcMethodNotFoundError() + + def _is_notification(self, request): + return 'id' not in request + + def _is_future(self, result): + return isinstance(result, pykka.Future) + + +class JsonRpcError(Exception): + code = -32000 + message = 'Unspecified server error' + + def __init__(self, data=None): + self.data = data + + def get_response(self, request_id=None): + response = { + 'jsonrpc': '2.0', + 'id': request_id, + 'error': { + 'code': self.code, + 'message': self.message, + }, + } + if self.data: + response['error']['data'] = self.data + return response + + +class JsonRpcParseError(JsonRpcError): + code = -32700 + message = 'Parse error' + + +class JsonRpcInvalidRequestError(JsonRpcError): + code = -32600 + message = 'Invalid Request' + + +class JsonRpcMethodNotFoundError(JsonRpcError): + code = -32601 + message = 'Method not found' + + +class JsonRpcInvalidParamsError(JsonRpcError): + code = -32602 + message = 'Invalid params' + + +class JsonRpcApplicationError(JsonRpcError): + code = 0 + message = 'Application error' + + +def get_combined_json_decoder(decoders): + def decode(dct): + for decoder in decoders: + dct = decoder(dct) + return dct + return decode + + +def get_combined_json_encoder(encoders): + class JsonRpcEncoder(json.JSONEncoder): + def default(self, obj): + for encoder in encoders: + try: + return encoder().default(obj) + except TypeError: + pass # Try next encoder + return json.JSONEncoder.default(self, obj) + return JsonRpcEncoder + + +class JsonRpcInspector(object): + """ + Inspects a group of classes and functions to create a description of what + methods they can expose over JSON-RPC 2.0. + + To inspect a single class, add it to the objects mapping using the empty + string as the key:: + + jri = JsonRpcInspector(objects={'': MyClass}) + + To inspect multiple classes, add them all to the objects mapping. The key + in the mapping is used as the classes' mounting point in the exposed API:: + + jri = JsonRpcInspector(objects={ + '': Foo, + 'hello': lambda: 'Hello, world!', + 'abc': Abc, + }) + + Since this inspector is based on inspecting classes and not instances, it + will not give you a complete picture of what is actually exported by + :class:`JsonRpcWrapper`. In particular: + + - it will not include methods added dynamically, and + - it will not include public methods on attributes on the instances that + are to be exposed. + + :param objects: mapping between mounts and exposed functions or classes + :type objects: dict + """ + + def __init__(self, objects): + self.objects = objects + + def describe(self): + """ + Inspects the object and returns a data structure which describes the + available properties and methods. + """ + methods = {} + for mount, obj in self.objects.iteritems(): + if inspect.isroutine(obj): + methods[mount] = self._describe_method(obj) + else: + obj_methods = self._get_methods(obj) + for name, description in obj_methods.iteritems(): + if mount: + name = '%s.%s' % (mount, name) + methods[name] = description + return methods + + def _get_methods(self, obj): + methods = {} + for name, value in inspect.getmembers(obj): + if name.startswith('_'): + continue + if not inspect.isroutine(value): + continue + method = self._describe_method(value) + if method: + methods[name] = method + return methods + + def _describe_method(self, method): + return { + 'description': inspect.getdoc(method), + 'params': self._describe_params(method), + } + + def _describe_params(self, method): + argspec = inspect.getargspec(method) + + defaults = argspec.defaults and list(argspec.defaults) or [] + num_args_without_default = len(argspec.args) - len(defaults) + no_defaults = [None] * num_args_without_default + defaults = no_defaults + defaults + + params = [] + + for arg, default in zip(argspec.args, defaults): + if arg == 'self': + continue + params.append({'name': arg}) + + if argspec.defaults: + for i, default in enumerate(reversed(argspec.defaults)): + params[len(params) - i - 1]['default'] = default + + if argspec.varargs: + params.append({ + 'name': argspec.varargs, + 'varargs': True, + }) + + if argspec.keywords: + params.append({ + 'name': argspec.keywords, + 'kwargs': True, + }) + + return params diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 0c06eedd..27be1864 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -13,14 +13,21 @@ import glib logger = logging.getLogger('mopidy.utils.path') -DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') -SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') -SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') +XDG_CACHE_DIR = glib.get_user_cache_dir().decode('utf-8') +XDG_CONFIG_DIR = glib.get_user_config_dir().decode('utf-8') +XDG_DATA_DIR = glib.get_user_data_dir().decode('utf-8') +XDG_MUSIC_DIR = glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC) +if XDG_MUSIC_DIR: + XDG_MUSIC_DIR = XDG_MUSIC_DIR.decode('utf-8') XDG_DIRS = { - 'XDG_CACHE_DIR': glib.get_user_cache_dir(), - 'XDG_DATA_DIR': glib.get_user_data_dir(), - 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), + 'XDG_CACHE_DIR': XDG_CACHE_DIR, + 'XDG_CONFIG_DIR': XDG_CONFIG_DIR, + 'XDG_DATA_DIR': XDG_DATA_DIR, + 'XDG_MUSIC_DIR': XDG_MUSIC_DIR, } +DATA_PATH = os.path.join(XDG_DATA_DIR, 'mopidy') +SETTINGS_PATH = os.path.join(XDG_CONFIG_DIR, 'mopidy') +SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') def get_or_create_folder(folder): diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py new file mode 100644 index 00000000..39ad8a81 --- /dev/null +++ b/tests/utils/jsonrpc_test.py @@ -0,0 +1,580 @@ +from __future__ import unicode_literals + +import json + +import pykka +import mock + +from mopidy import core, models +from mopidy.backends import dummy +from mopidy.utils import jsonrpc + +from tests import unittest + + +class Calculator(object): + def model(self): + return 'TI83' + + def add(self, a, b): + """Returns the sum of the given numbers""" + return a + b + + def sub(self, a, b): + return a - b + + def describe(self): + return { + 'add': 'Returns the sum of the terms', + 'sub': 'Returns the diff of the terms', + } + + def take_it_all(self, a, b, c=True, *args, **kwargs): + pass + + def _secret(self): + return 'Grand Unified Theory' + + +class JsonRpcTestBase(unittest.TestCase): + def setUp(self): + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + + self.jrw = jsonrpc.JsonRpcWrapper( + objects={ + 'hello': lambda: 'Hello, world!', + 'core': self.core, + '': Calculator(), + }, + encoders=[models.ModelJSONEncoder], + decoders=[models.model_json_decoder]) + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + +class JsonRpcSerializationTest(JsonRpcTestBase): + def test_handle_json_converts_from_and_to_json(self): + self.jrw.handle_data = mock.Mock() + self.jrw.handle_data.return_value = {'foo': 'response'} + + request = '{"foo": "request"}' + response = self.jrw.handle_json(request) + + self.jrw.handle_data.assert_called_once_with({'foo': 'request'}) + self.assertEqual(response, '{"foo": "response"}') + + def test_handle_json_decodes_mopidy_models(self): + self.jrw.handle_data = mock.Mock() + self.jrw.handle_data.return_value = [] + + request = '{"foo": {"__model__": "Artist", "name": "bar"}}' + self.jrw.handle_json(request) + + self.jrw.handle_data.assert_called_once_with( + {'foo': models.Artist(name='bar')}) + + def test_handle_json_encodes_mopidy_models(self): + self.jrw.handle_data = mock.Mock() + self.jrw.handle_data.return_value = {'foo': models.Artist(name='bar')} + + request = '[]' + response = self.jrw.handle_json(request) + + self.assertEqual( + response, '{"foo": {"__model__": "Artist", "name": "bar"}}') + + def test_handle_json_returns_nothing_for_notices(self): + request = '{"jsonrpc": "2.0", "method": "core.get_uri_schemes"}' + response = self.jrw.handle_json(request) + + self.assertEqual(response, None) + + def test_invalid_json_command_causes_parse_error(self): + request = ( + '{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]') + response = self.jrw.handle_json(request) + response = json.loads(response) + + self.assertEqual(response['jsonrpc'], '2.0') + error = response['error'] + self.assertEqual(error['code'], -32700) + self.assertEqual(error['message'], 'Parse error') + + def test_invalid_json_batch_causes_parse_error(self): + request = """[ + {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, + {"jsonrpc": "2.0", "method" + ]""" + response = self.jrw.handle_json(request) + response = json.loads(response) + + self.assertEqual(response['jsonrpc'], '2.0') + error = response['error'] + self.assertEqual(error['code'], -32700) + self.assertEqual(error['message'], 'Parse error') + + +class JsonRpcSingleCommandTest(JsonRpcTestBase): + def test_call_method_on_root(self): + request = { + 'jsonrpc': '2.0', + 'method': 'hello', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertEqual(response['jsonrpc'], '2.0') + self.assertEqual(response['id'], 1) + self.assertNotIn('error', response) + self.assertEqual(response['result'], 'Hello, world!') + + def test_call_method_on_plain_object_as_root(self): + request = { + 'jsonrpc': '2.0', + 'method': 'model', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertEqual(response['jsonrpc'], '2.0') + self.assertEqual(response['id'], 1) + self.assertNotIn('error', response) + self.assertEqual(response['result'], 'TI83') + + def test_call_method_which_returns_dict_from_plain_object(self): + request = { + 'jsonrpc': '2.0', + 'method': 'describe', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertEqual(response['jsonrpc'], '2.0') + self.assertEqual(response['id'], 1) + self.assertNotIn('error', response) + self.assertIn('add', response['result']) + self.assertIn('sub', response['result']) + + def test_call_method_on_actor_root(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.get_uri_schemes', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertEqual(response['jsonrpc'], '2.0') + self.assertEqual(response['id'], 1) + self.assertNotIn('error', response) + self.assertEqual(response['result'], ['dummy']) + + def test_call_method_on_actor_member(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.playback.get_volume', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertEqual(response['result'], None) + + def test_call_method_with_positional_params(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.playback.set_volume', + 'params': [37], + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertEqual(response['result'], None) + self.assertEqual(self.core.playback.get_volume().get(), 37) + + def test_call_methods_with_named_params(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.playback.set_volume', + 'params': {'volume': 37}, + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertEqual(response['result'], None) + self.assertEqual(self.core.playback.get_volume().get(), 37) + + +class JsonRpcSingleNotificationTest(JsonRpcTestBase): + def test_notification_does_not_return_a_result(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.get_uri_schemes', + } + response = self.jrw.handle_data(request) + + self.assertIsNone(response) + + def test_notification_makes_an_observable_change(self): + self.assertEqual(self.core.playback.get_volume().get(), None) + + request = { + 'jsonrpc': '2.0', + 'method': 'core.playback.set_volume', + 'params': [37], + } + response = self.jrw.handle_data(request) + + self.assertIsNone(response) + self.assertEqual(self.core.playback.get_volume().get(), 37) + + def test_notification_unknown_method_returns_nothing(self): + request = { + 'jsonrpc': '2.0', + 'method': 'bogus', + 'params': ['bogus'], + } + response = self.jrw.handle_data(request) + + self.assertIsNone(response) + + +class JsonRpcBatchTest(JsonRpcTestBase): + def test_batch_of_only_commands_returns_all(self): + self.core.playback.set_random(True).get() + + request = [ + {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat', 'id': 1}, + {'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2}, + {'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3}, + ] + response = self.jrw.handle_data(request) + + self.assertEqual(len(response), 3) + + response = {row['id']: row for row in response} + self.assertEqual(response[1]['result'], False) + self.assertEqual(response[2]['result'], True) + self.assertEqual(response[3]['result'], False) + + def test_batch_of_commands_and_notifications_returns_some(self): + self.core.playback.set_random(True).get() + + request = [ + {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'}, + {'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2}, + {'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3}, + ] + response = self.jrw.handle_data(request) + + self.assertEqual(len(response), 2) + + response = {row['id']: row for row in response} + self.assertNotIn(1, response) + self.assertEqual(response[2]['result'], True) + self.assertEqual(response[3]['result'], False) + + def test_batch_of_only_notifications_returns_nothing(self): + self.core.playback.set_random(True).get() + + request = [ + {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'}, + {'jsonrpc': '2.0', 'method': 'core.playback.get_random'}, + {'jsonrpc': '2.0', 'method': 'core.playback.get_single'}, + ] + response = self.jrw.handle_data(request) + + self.assertIsNone(response) + + +class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): + def test_application_error_response(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.tracklist.index', + 'params': ['bogus'], + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertNotIn('result', response) + + error = response['error'] + self.assertEqual(error['code'], 0) + self.assertEqual(error['message'], 'Application error') + + data = error['data'] + self.assertEqual(data['type'], 'ValueError') + self.assertEqual(data['message'], "u'bogus' is not in list") + self.assertIn('traceback', data) + self.assertIn('Traceback (most recent call last):', data['traceback']) + + def test_missing_jsonrpc_member_causes_invalid_request_error(self): + request = { + 'method': 'core.get_uri_schemes', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertIsNone(response['id']) + error = response['error'] + self.assertEqual(error['code'], -32600) + self.assertEqual(error['message'], 'Invalid Request') + self.assertEqual(error['data'], '"jsonrpc" member must be included') + + def test_wrong_jsonrpc_version_causes_invalid_request_error(self): + request = { + 'jsonrpc': '3.0', + 'method': 'core.get_uri_schemes', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertIsNone(response['id']) + error = response['error'] + self.assertEqual(error['code'], -32600) + self.assertEqual(error['message'], 'Invalid Request') + self.assertEqual(error['data'], '"jsonrpc" value must be "2.0"') + + def test_missing_method_member_causes_invalid_request_error(self): + request = { + 'jsonrpc': '2.0', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertIsNone(response['id']) + error = response['error'] + self.assertEqual(error['code'], -32600) + self.assertEqual(error['message'], 'Invalid Request') + self.assertEqual(error['data'], '"method" member must be included') + + def test_invalid_method_value_causes_invalid_request_error(self): + request = { + 'jsonrpc': '2.0', + 'method': 1, + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertIsNone(response['id']) + error = response['error'] + self.assertEqual(error['code'], -32600) + self.assertEqual(error['message'], 'Invalid Request') + self.assertEqual(error['data'], '"method" must be a string') + + def test_invalid_params_value_causes_invalid_request_error(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.get_uri_schemes', + 'params': 'foobar', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertIsNone(response['id']) + error = response['error'] + self.assertEqual(error['code'], -32600) + self.assertEqual(error['message'], 'Invalid Request') + self.assertEqual( + error['data'], '"params", if given, must be an array or an object') + + def test_unknown_method_causes_unknown_method_error(self): + request = { + 'jsonrpc': '2.0', + 'method': 'bogus', + 'params': ['bogus'], + 'id': 1, + } + response = self.jrw.handle_data(request) + + error = response['error'] + self.assertEqual(error['code'], -32601) + self.assertEqual(error['message'], 'Method not found') + + def test_private_method_causes_unknown_method_error(self): + request = { + 'jsonrpc': '2.0', + 'method': '_secret', + 'id': 1, + } + response = self.jrw.handle_data(request) + + error = response['error'] + self.assertEqual(error['code'], -32601) + self.assertEqual(error['message'], 'Method not found') + + def test_invalid_params_causes_invalid_params_error(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.get_uri_schemes', + 'params': ['bogus'], + 'id': 1, + } + response = self.jrw.handle_data(request) + + error = response['error'] + self.assertEqual(error['code'], -32602) + self.assertEqual(error['message'], 'Invalid params') + + data = error['data'] + self.assertEqual(data['type'], 'TypeError') + self.assertEqual( + data['message'], + 'get_uri_schemes() takes exactly 1 argument (2 given)') + self.assertIn('traceback', data) + self.assertIn('Traceback (most recent call last):', data['traceback']) + + +class JsonRpcBatchErrorTest(JsonRpcTestBase): + def test_empty_batch_list_causes_invalid_request_error(self): + request = [] + response = self.jrw.handle_data(request) + + self.assertIsNone(response['id']) + error = response['error'] + self.assertEqual(error['code'], -32600) + self.assertEqual(error['message'], 'Invalid Request') + self.assertEqual(error['data'], 'Batch list cannot be empty') + + def test_batch_with_invalid_command_causes_invalid_request_error(self): + request = [1] + response = self.jrw.handle_data(request) + + self.assertEqual(len(response), 1) + response = response[0] + self.assertIsNone(response['id']) + error = response['error'] + self.assertEqual(error['code'], -32600) + self.assertEqual(error['message'], 'Invalid Request') + self.assertEqual(error['data'], 'Request must be an object') + + def test_batch_with_invalid_commands_causes_invalid_request_error(self): + request = [1, 2, 3] + response = self.jrw.handle_data(request) + + self.assertEqual(len(response), 3) + response = response[2] + self.assertIsNone(response['id']) + error = response['error'] + self.assertEqual(error['code'], -32600) + self.assertEqual(error['message'], 'Invalid Request') + self.assertEqual(error['data'], 'Request must be an object') + + def test_batch_of_both_successfull_and_failing_requests(self): + request = [ + # Call with positional params + {'jsonrpc': '2.0', 'method': 'core.playback.set_volume', + 'params': [47], 'id': '1'}, + # Notification + {'jsonrpc': '2.0', 'method': 'core.playback.set_consume', + 'params': [True]}, + # Call with positional params + {'jsonrpc': '2.0', 'method': 'core.playback.set_repeat', + 'params': [False], 'id': '2'}, + # Invalid request + {'foo': 'boo'}, + # Unknown method + {'jsonrpc': '2.0', 'method': 'foo.get', + 'params': {'name': 'myself'}, 'id': '5'}, + # Call without params + {'jsonrpc': '2.0', 'method': 'core.playback.get_random', + 'id': '9'}, + ] + response = self.jrw.handle_data(request) + + self.assertEqual(len(response), 5) + response = {row['id']: row for row in response} + self.assertEqual(response['1']['result'], None) + self.assertEqual(response['2']['result'], None) + self.assertEqual(response[None]['error']['code'], -32600) + self.assertEqual(response['5']['error']['code'], -32601) + self.assertEqual(response['9']['result'], False) + + +class JsonRpcInspectorTest(JsonRpcTestBase): + def test_can_describe_method_on_root(self): + inspector = jsonrpc.JsonRpcInspector({ + 'hello': lambda: 'Hello, world!', + }) + + methods = inspector.describe() + + self.assertIn('hello', methods) + self.assertEqual(len(methods['hello']['params']), 0) + + def test_inspector_can_describe_methods_on_the_root_class(self): + inspector = jsonrpc.JsonRpcInspector({ + '': Calculator, + }) + + methods = inspector.describe() + + self.assertIn('add', methods) + self.assertEqual( + methods['add']['description'], + 'Returns the sum of the given numbers') + + self.assertIn('sub', methods) + self.assertIn('take_it_all', methods) + self.assertNotIn('_secret', methods) + self.assertNotIn('__init__', methods) + + method = methods['take_it_all'] + self.assertIn('params', method) + + params = method['params'] + + self.assertEqual(params[0]['name'], 'a') + self.assertNotIn('default', params[0]) + + self.assertEqual(params[1]['name'], 'b') + self.assertNotIn('default', params[1]) + + self.assertEqual(params[2]['name'], 'c') + self.assertEqual(params[2]['default'], True) + + self.assertEqual(params[3]['name'], 'args') + self.assertNotIn('default', params[3]) + self.assertEqual(params[3]['varargs'], True) + + self.assertEqual(params[4]['name'], 'kwargs') + self.assertNotIn('default', params[4]) + self.assertEqual(params[4]['kwargs'], True) + + def test_inspector_can_describe_methods_not_on_the_root(self): + inspector = jsonrpc.JsonRpcInspector({ + 'calc': Calculator, + }) + + methods = inspector.describe() + + self.assertIn('calc.add', methods) + self.assertIn('calc.sub', methods) + + def test_inspector_can_describe_a_bunch_of_large_classes(self): + inspector = jsonrpc.JsonRpcInspector({ + 'core.library': core.LibraryController, + 'core.playback': core.PlaybackController, + 'core.playlists': core.PlaylistsController, + 'core.tracklist': core.TracklistController, + }) + + methods = inspector.describe() + + self.assertIn('core.library.lookup', methods.keys()) + self.assertEquals( + methods['core.library.lookup']['params'][0]['name'], 'uri') + + self.assertIn('core.playback.next', methods) + self.assertEquals(len(methods['core.playback.next']['params']), 0) + + self.assertIn('core.playlists.get_playlists', methods) + self.assertEquals( + len(methods['core.playlists.get_playlists']['params']), 0) + + self.assertIn('core.tracklist.filter', methods.keys()) + self.assertEquals( + methods['core.tracklist.filter']['params'][0]['name'], 'criteria') + self.assertEquals( + methods['core.tracklist.filter']['params'][0]['kwargs'], True)