diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index a003567d..d3760d88 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import inspect import json import traceback @@ -245,3 +246,84 @@ def get_combined_json_encoder(encoders): pass # Try next encoder return json.JSONEncoder.default(self, obj) return JsonRpcEncoder + + +class JsonRpcInspector(object): + """ + Inspects a group of objects to create a description of what methods they + can expose over JSON-RPC 2.0. + + :param objects: mapping between mounts and exposed 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/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 022525f4..37a3b91a 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -21,6 +21,7 @@ class Calculator(object): return 'TI83' def add(self, a, b): + """Returns the sum of the given numbers""" return a + b def sub(self, a, b): @@ -32,6 +33,9 @@ class Calculator(object): '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' @@ -491,3 +495,91 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): 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)