Merge branch 'develop' into feature/http-frontend
This commit is contained in:
commit
7d047acc2e
@ -167,12 +167,19 @@ can install Mopidy from PyPI using Pip.
|
||||
|
||||
sudo pacman -S base-devel python2-pip
|
||||
|
||||
And on Fedora Linux from the official repositories::
|
||||
|
||||
sudo yum install -y gcc python-devel python-pip
|
||||
|
||||
#. Then you'll need to install all of Mopidy's hard dependencies:
|
||||
|
||||
- Pykka >= 1.0::
|
||||
|
||||
sudo pip install -U pykka
|
||||
|
||||
# On Fedora the binary is called pip-python:
|
||||
sudo pip-python install -U pykka
|
||||
|
||||
- GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most
|
||||
popular Linux distributions. Search for GStreamer in your package manager,
|
||||
and make sure to install the Python bindings, and the "good" and "ugly"
|
||||
@ -189,6 +196,11 @@ can install Mopidy from PyPI using Pip.
|
||||
sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \
|
||||
gstreamer0.10-ugly-plugins
|
||||
|
||||
If you use Fedora you can install GStreamer like this::
|
||||
|
||||
sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \
|
||||
gstreamer0.10-plugins-ugly gstreamer0.10-tools
|
||||
|
||||
#. Optional: If you want Spotify support in Mopidy, you'll need to install
|
||||
libspotify and the Python bindings, pyspotify.
|
||||
|
||||
@ -212,15 +224,27 @@ can install Mopidy from PyPI using Pip.
|
||||
Remember to adjust the above example for the latest libspotify version
|
||||
supported by pyspotify, your OS, and your CPU architecture.
|
||||
|
||||
#. If you're on Fedora, you must add a configuration file so libspotify.so
|
||||
can be found:
|
||||
|
||||
su -c 'echo "/usr/local/lib" > /etc/ld.so.conf.d/libspotify.conf'
|
||||
sudo ldconfig
|
||||
|
||||
#. Then get, build, and install the latest release of pyspotify using Pip::
|
||||
|
||||
sudo pip install -U pyspotify
|
||||
|
||||
# Fedora:
|
||||
sudo pip-python install -U pyspotify
|
||||
|
||||
#. Optional: If you want to scrobble your played tracks to Last.fm, you need
|
||||
pylast::
|
||||
|
||||
sudo pip install -U pylast
|
||||
|
||||
# Fedora:
|
||||
sudo pip-python install -U pylast
|
||||
|
||||
#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound
|
||||
Menu or from an UPnP client via Rygel, you need some additional
|
||||
dependencies: the Python bindings for libindicate, and the Python bindings
|
||||
@ -234,6 +258,9 @@ can install Mopidy from PyPI using Pip.
|
||||
|
||||
sudo pip install -U mopidy
|
||||
|
||||
# Fedora:
|
||||
sudo pip-python install -U mopidy
|
||||
|
||||
To upgrade Mopidy to future releases, just rerun this command.
|
||||
|
||||
Alternatively, if you want to track Mopidy development closer, you may
|
||||
|
||||
@ -6,32 +6,45 @@ from mopidy import settings
|
||||
from mopidy.models import Artist, Album, Track, Playlist
|
||||
|
||||
|
||||
artist_cache = {}
|
||||
album_cache = {}
|
||||
track_cache = {}
|
||||
|
||||
|
||||
def to_mopidy_artist(spotify_artist):
|
||||
if spotify_artist is None:
|
||||
return
|
||||
uri = str(Link.from_artist(spotify_artist))
|
||||
if uri in artist_cache:
|
||||
return artist_cache[uri]
|
||||
if not spotify_artist.is_loaded():
|
||||
return Artist(uri=uri, name='[loading...]')
|
||||
return Artist(uri=uri, name=spotify_artist.name())
|
||||
artist_cache[uri] = Artist(uri=uri, name=spotify_artist.name())
|
||||
return artist_cache[uri]
|
||||
|
||||
|
||||
def to_mopidy_album(spotify_album):
|
||||
if spotify_album is None:
|
||||
return
|
||||
uri = str(Link.from_album(spotify_album))
|
||||
if uri in album_cache:
|
||||
return album_cache[uri]
|
||||
if not spotify_album.is_loaded():
|
||||
return Album(uri=uri, name='[loading...]')
|
||||
return Album(
|
||||
album_cache[uri] = Album(
|
||||
uri=uri,
|
||||
name=spotify_album.name(),
|
||||
artists=[to_mopidy_artist(spotify_album.artist())],
|
||||
date=spotify_album.year())
|
||||
return album_cache[uri]
|
||||
|
||||
|
||||
def to_mopidy_track(spotify_track):
|
||||
if spotify_track is None:
|
||||
return
|
||||
uri = str(Link.from_track(spotify_track, 0))
|
||||
if uri in track_cache:
|
||||
return track_cache[uri]
|
||||
if not spotify_track.is_loaded():
|
||||
return Track(uri=uri, name='[loading...]')
|
||||
spotify_album = spotify_track.album()
|
||||
@ -39,7 +52,7 @@ def to_mopidy_track(spotify_track):
|
||||
date = spotify_album.year()
|
||||
else:
|
||||
date = None
|
||||
return Track(
|
||||
track_cache[uri] = Track(
|
||||
uri=uri,
|
||||
name=spotify_track.name(),
|
||||
artists=[to_mopidy_artist(a) for a in spotify_track.artists()],
|
||||
@ -48,6 +61,7 @@ def to_mopidy_track(spotify_track):
|
||||
date=date,
|
||||
length=spotify_track.duration(),
|
||||
bitrate=settings.SPOTIFY_BITRATE)
|
||||
return track_cache[uri]
|
||||
|
||||
|
||||
def to_mopidy_playlist(spotify_playlist):
|
||||
|
||||
@ -15,32 +15,27 @@ class JsonRpcWrapper(object):
|
||||
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::
|
||||
The wrapper supports exporting the methods of one or more objects. Either
|
||||
way, the objects must be exported with method name prefixes, called
|
||||
"mounts".
|
||||
|
||||
jrw = JsonRpcWrapper(objects={'': my_object})
|
||||
To expose 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::
|
||||
|
||||
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': foo,
|
||||
'hello': lambda: 'Hello, world!',
|
||||
})
|
||||
|
||||
jrw = JsonRpcWrapper(objects={
|
||||
'': foo,
|
||||
'hello': lambda: 'Hello, world!',
|
||||
'abc': abc,
|
||||
})
|
||||
This will export the Python callables on the left as the JSON-RPC 2.0
|
||||
method names on the right::
|
||||
|
||||
This will create the following mapping between JSON-RPC 2.0 method names
|
||||
and Python callables::
|
||||
foo.bar() -> foo.bar
|
||||
foo.baz() -> foo.baz
|
||||
lambda -> hello
|
||||
|
||||
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.
|
||||
Only the public methods of the mounted objects, or functions/methods
|
||||
included directly in the mapping, will be exposed.
|
||||
|
||||
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.
|
||||
@ -59,32 +54,13 @@ class JsonRpcWrapper(object):
|
||||
"""
|
||||
|
||||
def __init__(self, objects, decoders=None, encoders=None):
|
||||
self.obj = self._build_exported_object(objects)
|
||||
if '' in objects.keys():
|
||||
raise AttributeError(
|
||||
'The empty string is not allowed as an object mount')
|
||||
self.objects = 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.
|
||||
@ -151,8 +127,7 @@ class JsonRpcWrapper(object):
|
||||
if self._is_notification(request):
|
||||
return None
|
||||
|
||||
if self._is_future(result):
|
||||
result = result.get()
|
||||
result = self._unwrap_result(result)
|
||||
|
||||
return {
|
||||
'jsonrpc': '2.0',
|
||||
@ -205,23 +180,44 @@ class JsonRpcWrapper(object):
|
||||
raise JsonRpcInvalidRequestError(
|
||||
data='"params", if given, must be an array or an object')
|
||||
|
||||
def _get_method(self, name):
|
||||
def _get_method(self, method_path):
|
||||
if inspect.isroutine(self.objects.get(method_path, None)):
|
||||
# The mounted object is the callable
|
||||
return self.objects[method_path]
|
||||
|
||||
# The mounted object contains the callable
|
||||
|
||||
if '.' not in method_path:
|
||||
raise JsonRpcMethodNotFoundError(
|
||||
data='Could not find object mount in method name "%s"' % (
|
||||
method_path))
|
||||
|
||||
mount, method_name = method_path.rsplit('.', 1)
|
||||
|
||||
if method_name.startswith('_'):
|
||||
raise JsonRpcMethodNotFoundError(
|
||||
data='Private methods are not exported')
|
||||
|
||||
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()
|
||||
obj = self.objects[mount]
|
||||
except KeyError:
|
||||
raise JsonRpcMethodNotFoundError(
|
||||
data='No object found at "%s"' % mount)
|
||||
|
||||
try:
|
||||
return getattr(obj, method_name)
|
||||
except AttributeError:
|
||||
raise JsonRpcMethodNotFoundError(
|
||||
data='Object mounted at "%s" has no member "%s"' % (
|
||||
mount, method_name))
|
||||
|
||||
def _is_notification(self, request):
|
||||
return 'id' not in request
|
||||
|
||||
def _is_future(self, result):
|
||||
return isinstance(result, pykka.Future)
|
||||
def _unwrap_result(self, result):
|
||||
if isinstance(result, pykka.Future):
|
||||
result = result.get()
|
||||
return result
|
||||
|
||||
|
||||
class JsonRpcError(Exception):
|
||||
@ -295,33 +291,27 @@ 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::
|
||||
To inspect one or more 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,
|
||||
'foo': 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.
|
||||
Since the inspector is based on inspecting classes and not instances, it
|
||||
will not include methods added dynamically. The wrapper works with
|
||||
instances, and it will thus export dynamically added methods as well.
|
||||
|
||||
:param objects: mapping between mounts and exposed functions or classes
|
||||
:type objects: dict
|
||||
"""
|
||||
|
||||
def __init__(self, objects):
|
||||
if '' in objects.keys():
|
||||
raise AttributeError(
|
||||
'The empty string is not allowed as an object mount')
|
||||
self.objects = objects
|
||||
|
||||
def describe(self):
|
||||
|
||||
@ -25,9 +25,9 @@ XDG_DIRS = {
|
||||
'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')
|
||||
DATA_PATH = os.path.join(unicode(XDG_DATA_DIR), 'mopidy')
|
||||
SETTINGS_PATH = os.path.join(unicode(XDG_CONFIG_DIR), 'mopidy')
|
||||
SETTINGS_FILE = os.path.join(unicode(SETTINGS_PATH), 'settings.py')
|
||||
|
||||
|
||||
def get_or_create_folder(folder):
|
||||
|
||||
@ -44,8 +44,10 @@ class JsonRpcTestBase(unittest.TestCase):
|
||||
self.jrw = jsonrpc.JsonRpcWrapper(
|
||||
objects={
|
||||
'hello': lambda: 'Hello, world!',
|
||||
'calc': Calculator(),
|
||||
'core': self.core,
|
||||
'': Calculator(),
|
||||
'core.playback': self.core.playback,
|
||||
'core.tracklist': self.core.tracklist,
|
||||
},
|
||||
encoders=[models.ModelJSONEncoder],
|
||||
decoders=[models.model_json_decoder])
|
||||
@ -54,6 +56,12 @@ class JsonRpcTestBase(unittest.TestCase):
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
|
||||
class JsonRpcSetupTest(JsonRpcTestBase):
|
||||
def test_empty_object_mounts_is_not_allowed(self):
|
||||
test = lambda: jsonrpc.JsonRpcWrapper(objects={'': Calculator()})
|
||||
self.assertRaises(AttributeError, test)
|
||||
|
||||
|
||||
class JsonRpcSerializationTest(JsonRpcTestBase):
|
||||
def test_handle_json_converts_from_and_to_json(self):
|
||||
self.jrw.handle_data = mock.Mock()
|
||||
@ -130,10 +138,10 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase):
|
||||
self.assertNotIn('error', response)
|
||||
self.assertEqual(response['result'], 'Hello, world!')
|
||||
|
||||
def test_call_method_on_plain_object_as_root(self):
|
||||
def test_call_method_on_plain_object(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'model',
|
||||
'method': 'calc.model',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
@ -146,7 +154,7 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase):
|
||||
def test_call_method_which_returns_dict_from_plain_object(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'describe',
|
||||
'method': 'calc.describe',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
@ -379,11 +387,10 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
|
||||
self.assertEqual(
|
||||
error['data'], '"params", if given, must be an array or an object')
|
||||
|
||||
def test_unknown_method_causes_unknown_method_error(self):
|
||||
def test_method_on_without_object_causes_unknown_method_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'bogus',
|
||||
'params': ['bogus'],
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
@ -391,11 +398,41 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32601)
|
||||
self.assertEqual(error['message'], 'Method not found')
|
||||
self.assertEqual(
|
||||
error['data'],
|
||||
'Could not find object mount in method name "bogus"')
|
||||
|
||||
def test_method_on_unknown_object_causes_unknown_method_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'bogus.bogus',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32601)
|
||||
self.assertEqual(error['message'], 'Method not found')
|
||||
self.assertEqual(error['data'], 'No object found at "bogus"')
|
||||
|
||||
def test_unknown_method_on_known_object_causes_unknown_method_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.bogus',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32601)
|
||||
self.assertEqual(error['message'], 'Method not found')
|
||||
self.assertEqual(
|
||||
error['data'], 'Object mounted at "core" has no member "bogus"')
|
||||
|
||||
def test_private_method_causes_unknown_method_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': '_secret',
|
||||
'method': 'core._secret',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
@ -403,6 +440,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32601)
|
||||
self.assertEqual(error['message'], 'Method not found')
|
||||
self.assertEqual(error['data'], 'Private methods are not exported')
|
||||
|
||||
def test_invalid_params_causes_invalid_params_error(self):
|
||||
request = {
|
||||
@ -493,6 +531,10 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase):
|
||||
|
||||
|
||||
class JsonRpcInspectorTest(JsonRpcTestBase):
|
||||
def test_empty_object_mounts_is_not_allowed(self):
|
||||
test = lambda: jsonrpc.JsonRpcInspector(objects={'': Calculator})
|
||||
self.assertRaises(AttributeError, test)
|
||||
|
||||
def test_can_describe_method_on_root(self):
|
||||
inspector = jsonrpc.JsonRpcInspector({
|
||||
'hello': lambda: 'Hello, world!',
|
||||
@ -503,24 +545,24 @@ class JsonRpcInspectorTest(JsonRpcTestBase):
|
||||
self.assertIn('hello', methods)
|
||||
self.assertEqual(len(methods['hello']['params']), 0)
|
||||
|
||||
def test_inspector_can_describe_methods_on_the_root_class(self):
|
||||
def test_inspector_can_describe_an_object_with_methods(self):
|
||||
inspector = jsonrpc.JsonRpcInspector({
|
||||
'': Calculator,
|
||||
'calc': Calculator,
|
||||
})
|
||||
|
||||
methods = inspector.describe()
|
||||
|
||||
self.assertIn('add', methods)
|
||||
self.assertIn('calc.add', methods)
|
||||
self.assertEqual(
|
||||
methods['add']['description'],
|
||||
methods['calc.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)
|
||||
self.assertIn('calc.sub', methods)
|
||||
self.assertIn('calc.take_it_all', methods)
|
||||
self.assertNotIn('calc._secret', methods)
|
||||
self.assertNotIn('calc.__init__', methods)
|
||||
|
||||
method = methods['take_it_all']
|
||||
method = methods['calc.take_it_all']
|
||||
self.assertIn('params', method)
|
||||
|
||||
params = method['params']
|
||||
@ -542,16 +584,6 @@ class JsonRpcInspectorTest(JsonRpcTestBase):
|
||||
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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user