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
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

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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,