Merge branch 'develop' into feature/http-frontend

This commit is contained in:
Stein Magnus Jodal 2012-11-25 22:04:53 +01:00
commit 7d047acc2e
5 changed files with 169 additions and 106 deletions

View File

@ -167,12 +167,19 @@ can install Mopidy from PyPI using Pip.
sudo pacman -S base-devel python2-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: #. Then you'll need to install all of Mopidy's hard dependencies:
- Pykka >= 1.0:: - Pykka >= 1.0::
sudo pip install -U pykka 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 - GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most
popular Linux distributions. Search for GStreamer in your package manager, popular Linux distributions. Search for GStreamer in your package manager,
and make sure to install the Python bindings, and the "good" and "ugly" 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 \ sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \
gstreamer0.10-ugly-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 #. Optional: If you want Spotify support in Mopidy, you'll need to install
libspotify and the Python bindings, pyspotify. 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 Remember to adjust the above example for the latest libspotify version
supported by pyspotify, your OS, and your CPU architecture. 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:: #. Then get, build, and install the latest release of pyspotify using Pip::
sudo pip install -U pyspotify 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 #. Optional: If you want to scrobble your played tracks to Last.fm, you need
pylast:: pylast::
sudo pip install -U 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 #. 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 Menu or from an UPnP client via Rygel, you need some additional
dependencies: the Python bindings for libindicate, and the Python bindings 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 sudo pip install -U mopidy
# Fedora:
sudo pip-python install -U mopidy
To upgrade Mopidy to future releases, just rerun this command. To upgrade Mopidy to future releases, just rerun this command.
Alternatively, if you want to track Mopidy development closer, you may Alternatively, if you want to track Mopidy development closer, you may

View File

@ -6,32 +6,45 @@ from mopidy import settings
from mopidy.models import Artist, Album, Track, Playlist from mopidy.models import Artist, Album, Track, Playlist
artist_cache = {}
album_cache = {}
track_cache = {}
def to_mopidy_artist(spotify_artist): def to_mopidy_artist(spotify_artist):
if spotify_artist is None: if spotify_artist is None:
return return
uri = str(Link.from_artist(spotify_artist)) uri = str(Link.from_artist(spotify_artist))
if uri in artist_cache:
return artist_cache[uri]
if not spotify_artist.is_loaded(): if not spotify_artist.is_loaded():
return Artist(uri=uri, name='[loading...]') 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): def to_mopidy_album(spotify_album):
if spotify_album is None: if spotify_album is None:
return return
uri = str(Link.from_album(spotify_album)) uri = str(Link.from_album(spotify_album))
if uri in album_cache:
return album_cache[uri]
if not spotify_album.is_loaded(): if not spotify_album.is_loaded():
return Album(uri=uri, name='[loading...]') return Album(uri=uri, name='[loading...]')
return Album( album_cache[uri] = Album(
uri=uri, uri=uri,
name=spotify_album.name(), name=spotify_album.name(),
artists=[to_mopidy_artist(spotify_album.artist())], artists=[to_mopidy_artist(spotify_album.artist())],
date=spotify_album.year()) date=spotify_album.year())
return album_cache[uri]
def to_mopidy_track(spotify_track): def to_mopidy_track(spotify_track):
if spotify_track is None: if spotify_track is None:
return return
uri = str(Link.from_track(spotify_track, 0)) uri = str(Link.from_track(spotify_track, 0))
if uri in track_cache:
return track_cache[uri]
if not spotify_track.is_loaded(): if not spotify_track.is_loaded():
return Track(uri=uri, name='[loading...]') return Track(uri=uri, name='[loading...]')
spotify_album = spotify_track.album() spotify_album = spotify_track.album()
@ -39,7 +52,7 @@ def to_mopidy_track(spotify_track):
date = spotify_album.year() date = spotify_album.year()
else: else:
date = None date = None
return Track( track_cache[uri] = Track(
uri=uri, uri=uri,
name=spotify_track.name(), name=spotify_track.name(),
artists=[to_mopidy_artist(a) for a in spotify_track.artists()], artists=[to_mopidy_artist(a) for a in spotify_track.artists()],
@ -48,6 +61,7 @@ def to_mopidy_track(spotify_track):
date=date, date=date,
length=spotify_track.duration(), length=spotify_track.duration(),
bitrate=settings.SPOTIFY_BITRATE) bitrate=settings.SPOTIFY_BITRATE)
return track_cache[uri]
def to_mopidy_playlist(spotify_playlist): def to_mopidy_playlist(spotify_playlist):

View File

@ -15,32 +15,27 @@ class JsonRpcWrapper(object):
processing of JSON-RPC 2.0 messages. The transport of the messages over 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. 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 The wrapper supports exporting the methods of one or more objects. Either
string as the key:: 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 jrw = JsonRpcWrapper(objects={
the mapping is used as the object's mounting point in the exposed API:: 'foo': foo,
'hello': lambda: 'Hello, world!',
})
jrw = JsonRpcWrapper(objects={ This will export the Python callables on the left as the JSON-RPC 2.0
'': foo, method names on the right::
'hello': lambda: 'Hello, world!',
'abc': abc,
})
This will create the following mapping between JSON-RPC 2.0 method names foo.bar() -> foo.bar
and Python callables:: foo.baz() -> foo.baz
lambda -> hello
bar -> foo.bar() Only the public methods of the mounted objects, or functions/methods
baz -> foo.baz() included directly in the mapping, will be exposed.
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 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. 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): 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.decoder = get_combined_json_decoder(decoders or [])
self.encoder = get_combined_json_encoder(encoders 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): def handle_json(self, request):
""" """
Handles an incoming request encoded as a JSON string. Handles an incoming request encoded as a JSON string.
@ -151,8 +127,7 @@ class JsonRpcWrapper(object):
if self._is_notification(request): if self._is_notification(request):
return None return None
if self._is_future(result): result = self._unwrap_result(result)
result = result.get()
return { return {
'jsonrpc': '2.0', 'jsonrpc': '2.0',
@ -205,23 +180,44 @@ class JsonRpcWrapper(object):
raise JsonRpcInvalidRequestError( raise JsonRpcInvalidRequestError(
data='"params", if given, must be an array or an object') 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: try:
path = name.split('.') obj = self.objects[mount]
this = self.obj except KeyError:
for part in path: raise JsonRpcMethodNotFoundError(
if part.startswith('_'): data='No object found at "%s"' % mount)
raise AttributeError
this = getattr(this, part) try:
return this return getattr(obj, method_name)
except (AttributeError, KeyError): except AttributeError:
raise JsonRpcMethodNotFoundError() raise JsonRpcMethodNotFoundError(
data='Object mounted at "%s" has no member "%s"' % (
mount, method_name))
def _is_notification(self, request): def _is_notification(self, request):
return 'id' not in request return 'id' not in request
def _is_future(self, result): def _unwrap_result(self, result):
return isinstance(result, pykka.Future) if isinstance(result, pykka.Future):
result = result.get()
return result
class JsonRpcError(Exception): class JsonRpcError(Exception):
@ -295,33 +291,27 @@ class JsonRpcInspector(object):
Inspects a group of classes and functions to create a description of what Inspects a group of classes and functions to create a description of what
methods they can expose over JSON-RPC 2.0. methods they can expose over JSON-RPC 2.0.
To inspect a single class, add it to the objects mapping using the empty To inspect one or more classes, add them all to the objects mapping. The
string as the key:: key in the mapping is used as the classes' mounting point in the exposed
API::
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={ jri = JsonRpcInspector(objects={
'': Foo, 'foo': Foo,
'hello': lambda: 'Hello, world!', 'hello': lambda: 'Hello, world!',
'abc': Abc,
}) })
Since this inspector is based on inspecting classes and not instances, it Since the inspector is based on inspecting classes and not instances, it
will not give you a complete picture of what is actually exported by will not include methods added dynamically. The wrapper works with
:class:`JsonRpcWrapper`. In particular: instances, and it will thus export dynamically added methods as well.
- 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 :param objects: mapping between mounts and exposed functions or classes
:type objects: dict :type objects: dict
""" """
def __init__(self, objects): def __init__(self, objects):
if '' in objects.keys():
raise AttributeError(
'The empty string is not allowed as an object mount')
self.objects = objects self.objects = objects
def describe(self): def describe(self):

View File

@ -25,9 +25,9 @@ XDG_DIRS = {
'XDG_DATA_DIR': XDG_DATA_DIR, 'XDG_DATA_DIR': XDG_DATA_DIR,
'XDG_MUSIC_DIR': XDG_MUSIC_DIR, 'XDG_MUSIC_DIR': XDG_MUSIC_DIR,
} }
DATA_PATH = os.path.join(XDG_DATA_DIR, 'mopidy') DATA_PATH = os.path.join(unicode(XDG_DATA_DIR), 'mopidy')
SETTINGS_PATH = os.path.join(XDG_CONFIG_DIR, 'mopidy') SETTINGS_PATH = os.path.join(unicode(XDG_CONFIG_DIR), 'mopidy')
SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') SETTINGS_FILE = os.path.join(unicode(SETTINGS_PATH), 'settings.py')
def get_or_create_folder(folder): def get_or_create_folder(folder):

View File

@ -44,8 +44,10 @@ class JsonRpcTestBase(unittest.TestCase):
self.jrw = jsonrpc.JsonRpcWrapper( self.jrw = jsonrpc.JsonRpcWrapper(
objects={ objects={
'hello': lambda: 'Hello, world!', 'hello': lambda: 'Hello, world!',
'calc': Calculator(),
'core': self.core, 'core': self.core,
'': Calculator(), 'core.playback': self.core.playback,
'core.tracklist': self.core.tracklist,
}, },
encoders=[models.ModelJSONEncoder], encoders=[models.ModelJSONEncoder],
decoders=[models.model_json_decoder]) decoders=[models.model_json_decoder])
@ -54,6 +56,12 @@ class JsonRpcTestBase(unittest.TestCase):
pykka.ActorRegistry.stop_all() 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): class JsonRpcSerializationTest(JsonRpcTestBase):
def test_handle_json_converts_from_and_to_json(self): def test_handle_json_converts_from_and_to_json(self):
self.jrw.handle_data = mock.Mock() self.jrw.handle_data = mock.Mock()
@ -130,10 +138,10 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase):
self.assertNotIn('error', response) self.assertNotIn('error', response)
self.assertEqual(response['result'], 'Hello, world!') 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 = { request = {
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'model', 'method': 'calc.model',
'id': 1, 'id': 1,
} }
response = self.jrw.handle_data(request) response = self.jrw.handle_data(request)
@ -146,7 +154,7 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase):
def test_call_method_which_returns_dict_from_plain_object(self): def test_call_method_which_returns_dict_from_plain_object(self):
request = { request = {
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'describe', 'method': 'calc.describe',
'id': 1, 'id': 1,
} }
response = self.jrw.handle_data(request) response = self.jrw.handle_data(request)
@ -379,11 +387,10 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
self.assertEqual( self.assertEqual(
error['data'], '"params", if given, must be an array or an object') 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 = { request = {
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'bogus', 'method': 'bogus',
'params': ['bogus'],
'id': 1, 'id': 1,
} }
response = self.jrw.handle_data(request) response = self.jrw.handle_data(request)
@ -391,11 +398,41 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
error = response['error'] error = response['error']
self.assertEqual(error['code'], -32601) self.assertEqual(error['code'], -32601)
self.assertEqual(error['message'], 'Method not found') 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): def test_private_method_causes_unknown_method_error(self):
request = { request = {
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': '_secret', 'method': 'core._secret',
'id': 1, 'id': 1,
} }
response = self.jrw.handle_data(request) response = self.jrw.handle_data(request)
@ -403,6 +440,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
error = response['error'] error = response['error']
self.assertEqual(error['code'], -32601) self.assertEqual(error['code'], -32601)
self.assertEqual(error['message'], 'Method not found') 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): def test_invalid_params_causes_invalid_params_error(self):
request = { request = {
@ -493,6 +531,10 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase):
class JsonRpcInspectorTest(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): def test_can_describe_method_on_root(self):
inspector = jsonrpc.JsonRpcInspector({ inspector = jsonrpc.JsonRpcInspector({
'hello': lambda: 'Hello, world!', 'hello': lambda: 'Hello, world!',
@ -503,24 +545,24 @@ class JsonRpcInspectorTest(JsonRpcTestBase):
self.assertIn('hello', methods) self.assertIn('hello', methods)
self.assertEqual(len(methods['hello']['params']), 0) 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({ inspector = jsonrpc.JsonRpcInspector({
'': Calculator, 'calc': Calculator,
}) })
methods = inspector.describe() methods = inspector.describe()
self.assertIn('add', methods) self.assertIn('calc.add', methods)
self.assertEqual( self.assertEqual(
methods['add']['description'], methods['calc.add']['description'],
'Returns the sum of the given numbers') 'Returns the sum of the given numbers')
self.assertIn('sub', methods) self.assertIn('calc.sub', methods)
self.assertIn('take_it_all', methods) self.assertIn('calc.take_it_all', methods)
self.assertNotIn('_secret', methods) self.assertNotIn('calc._secret', methods)
self.assertNotIn('__init__', methods) self.assertNotIn('calc.__init__', methods)
method = methods['take_it_all'] method = methods['calc.take_it_all']
self.assertIn('params', method) self.assertIn('params', method)
params = method['params'] params = method['params']
@ -542,16 +584,6 @@ class JsonRpcInspectorTest(JsonRpcTestBase):
self.assertNotIn('default', params[4]) self.assertNotIn('default', params[4])
self.assertEqual(params[4]['kwargs'], True) 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): def test_inspector_can_describe_a_bunch_of_large_classes(self):
inspector = jsonrpc.JsonRpcInspector({ inspector = jsonrpc.JsonRpcInspector({
'core.library': core.LibraryController, 'core.library': core.LibraryController,