Merge branch 'develop' into feature/http-helpers

Conflicts:
	docs/changelog.rst
This commit is contained in:
Thomas Adamcik 2015-05-09 00:45:09 +02:00
commit f6f445e4b6
106 changed files with 1318 additions and 465 deletions

View File

@ -1,8 +1,8 @@
.. _concepts:
*************************
Architecture and concepts
*************************
************
Architecture
************
The overall architecture of Mopidy is organized around multiple frontends and
backends. The frontends use the core API. The core actor makes multiple backends

View File

@ -1,8 +1,8 @@
.. _audio-api:
*********
Audio API
*********
*********************************
:mod:`mopidy.audio` --- Audio API
*********************************
.. module:: mopidy.audio
:synopsis: Thin wrapper around the parts of GStreamer we use

View File

@ -1,8 +1,8 @@
.. _backend-api:
***********
Backend API
***********
*************************************
:mod:`mopidy.backend` --- Backend API
*************************************
.. module:: mopidy.backend
:synopsis: The API implemented by backends

View File

@ -1,8 +1,8 @@
.. _commands-api:
************
Commands API
************
***************************************
:mod:`mopidy.commands` --- Commands API
***************************************
.. automodule:: mopidy.commands
:synopsis: Commands API for Mopidy CLI.

View File

@ -1,8 +1,8 @@
.. _config-api:
**********
Config API
**********
***********************************
:mod:`mopidy.config` --- Config API
***********************************
.. automodule:: mopidy.config
:synopsis: Config API for config loading and validation

View File

@ -1,8 +1,8 @@
.. _core-api:
********
Core API
********
*******************************
:mod:`mopidy.core` --- Core API
*******************************
.. module:: mopidy.core
:synopsis: Core API for use by frontends

View File

@ -1,8 +1,8 @@
.. _ext-api:
*************
Extension API
*************
**********************************
:mod:`mopidy.ext` -- Extension API
**********************************
If you want to learn how to make Mopidy extensions, read :ref:`extensiondev`.

View File

@ -25,6 +25,8 @@ For details on how to make a Mopidy extension, see the :ref:`extensiondev`
guide.
.. _static-web-client:
Static web client example
=========================

View File

@ -4,9 +4,6 @@
HTTP JSON-RPC API
*****************
.. module:: mopidy.http
:synopsis: The HTTP frontend APIs
The :ref:`ext-http` extension makes Mopidy's :ref:`core-api` available using
JSON-RPC over HTTP using HTTP POST and WebSockets. We also provide a JavaScript
wrapper, called :ref:`Mopidy.js <mopidy-js>`, around the JSON-RPC over
@ -65,14 +62,9 @@ JSON-RPC 2.0 messages can be recognized by checking for the key named
please refer to the `JSON-RPC 2.0 spec
<http://www.jsonrpc.org/specification>`_.
All methods (not attributes) in the :ref:`core-api` is made available through
JSON-RPC calls over the WebSocket. For example,
:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method
``core.playback.play``.
The core API's attributes is made available through setters and getters. For
example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is
available as the JSON-RPC method ``core.playback.get_current_track``.
All methods in the :ref:`core-api` is made available through JSON-RPC calls
over the WebSocket. For example, :meth:`mopidy.core.PlaybackController.play` is
available as the JSON-RPC method ``core.playback.play``.
Example JSON-RPC request::

View File

@ -4,26 +4,56 @@
API reference
*************
.. note:: What is public?
.. note::
Only APIs documented here are public and open for use by Mopidy
extensions.
.. toctree::
:glob:
Concepts
========
concepts
.. toctree::
architecture
models
backends
Basics
======
.. toctree::
core
audio
mixer
frontends
commands
frontend
backend
ext
config
zeroconf
Web/JavaScript
==============
.. toctree::
http-server
http
js
Audio
=====
.. toctree::
audio
mixer
Utilities
=========
.. toctree::
config
commands
zeroconf

View File

@ -21,9 +21,9 @@ available at:
You may need to adjust hostname and port for your local setup.
Thus, if you use Mopidy to host your web client, like described above, you can
load the latest version of Mopidy.js by adding the following script tag to your
HTML file:
Thus, if you use Mopidy to host your web client, like described in
:ref:`static-web-client`, you can load the latest version of Mopidy.js by
adding the following script tag to your HTML file:
.. code-block:: html
@ -189,13 +189,10 @@ you've hooked up an errback (more on that a bit later) to the promise returned
from the call, the errback will be called with a ``Mopidy.ConnectionError``
instance.
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core
API attributes is *not* available, but that shouldn't be a problem as we've
added (undocumented) getters and setters for all of them, so you can access the
attributes as well from JavaScript. For example, the
:attr:`mopidy.core.PlaybackController.state` attribute is available in
JSON-RPC as the method ``core.playback.get_state`` and in Mopidy.js as
``mopidy.playback.getState()``.
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. For
example, the :meth:`mopidy.core.PlaybackController.get_state` method is
available in JSON-RPC as the method ``core.playback.get_state`` and in
Mopidy.js as ``mopidy.playback.getState()``.
Both the WebSocket API and the JavaScript API are based on introspection of the
core Python API. Thus, they will always be up to date and immediately reflect
@ -218,8 +215,7 @@ by looking at the method's ``description`` and ``params`` attributes:
JSON-RPC 2.0 limits method parameters to be sent *either* by-position or
by-name. Combinations of both, like we're used to from Python, isn't supported
by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports
passing parameters by-position.
by JSON-RPC 2.0.
Obviously, you'll want to get a return value from many of your method calls.
Since everything is happening across the WebSocket and maybe even across the
@ -272,8 +268,9 @@ passing it as the second argument to ``done()``:
.done(printCurrentTrack, console.error.bind(console));
If you don't hook up an error handler function and never call ``done()`` on the
promise object, when.js will log warnings to the console that you have
unhandled errors. In general, unhandled errors will not go silently missing.
promise object, warnings will be logged to the console complaining that you
have unhandled errors. In general, unhandled errors will not go silently
missing.
The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the

View File

@ -1,8 +1,8 @@
.. _mixer-api:
***************
Audio mixer API
***************
***************************************
:mod:`mopidy.mixer` --- Audio mixer API
***************************************
.. module:: mopidy.mixer
:synopsis: The audio mixer API

View File

@ -1,6 +1,6 @@
***********
Data models
***********
************************************
:mod:`mopidy.models` --- Data models
************************************
These immutable data models are used for all data transfer within the Mopidy
backends and between the backends and the MPD frontend. All fields are optional
@ -77,8 +77,29 @@ Data model helpers
.. autoclass:: mopidy.models.ImmutableObject
:members:
.. autoclass:: mopidy.models.Field
.. autoclass:: mopidy.models.ValidatedImmutableObject
:members: replace
Data model (de)serialization
----------------------------
.. autofunction:: mopidy.models.model_json_decoder
.. autoclass:: mopidy.models.ModelJSONEncoder
.. autofunction:: mopidy.models.model_json_decoder
Data model field types
----------------------
.. autoclass:: mopidy.models.fields.Field
.. autoclass:: mopidy.models.fields.String
.. autoclass:: mopidy.models.fields.Identifier
.. autoclass:: mopidy.models.fields.URI
.. autoclass:: mopidy.models.fields.Date
.. autoclass:: mopidy.models.fields.Integer
.. autoclass:: mopidy.models.fields.Collection

View File

@ -1,8 +1,8 @@
.. _zeroconf-api:
************
Zeroconf API
************
***************************************
:mod:`mopidy.zeroconf` --- Zeroconf API
***************************************
.. module:: mopidy.zeroconf
:synopsis: Helper for publishing of services on Zeroconf

View File

@ -12,10 +12,11 @@ Core API
- Calling the following methods with ``kwargs`` is being deprecated.
(PR: :issue:`1090`)
- :meth:`mopidy.core.library.LibraryController.search`
- :meth:`mopidy.core.library.PlaylistsController.filter`
- :meth:`mopidy.core.library.TracklistController.filter`
- :meth:`mopidy.core.library.TracklistController.remove`
- :meth:`mopidy.core.library.LibraryController.search`
- :meth:`mopidy.core.library.PlaylistsController.filter`
- :meth:`mopidy.core.library.TracklistController.filter`
- :meth:`mopidy.core.library.TracklistController.remove`
- Updated core controllers to handle backend exceptions in all calls that rely
on multiple backends. (Issue: :issue:`667`)
@ -27,6 +28,10 @@ Core API
``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`,
:issue:`1140`)
- Add :meth:`mopidy.core.playback.PlaybackController.get_current_tlid`.
(Part of: :issue:`1137`)
- Update core to handle backend crashes and bad data. (Fixes: :issue:`1161`)
Models
------
@ -58,6 +63,26 @@ Internal changes
:issue:`1115`)
v1.0.5 (UNRELEASED)
===================
Bug fix release.
- Core: Add workaround for playlist providers that do not support
creating playlists. (Fixes: :issue:`1162`, PR :issue:`1165`)
v1.0.4 (2015-04-30)
===================
Bug fix release.
- Audio: Since all previous attempts at tweaking the queuing for :issue:`1097`
seems to break things in subtle ways for different users. We are giving up
on tweaking the defaults and just going to live with a bit more lag on
software volume changes. (Fixes: :issue:`1147`)
v1.0.3 (2015-04-28)
===================
@ -69,7 +94,7 @@ Bug fix release.
- Audio: Follow-up fix for :issue:`1097` still exhibits issues for certain
setups. We are giving this get an other go by setting the buffer size to
maximum 100ms instead of a fixed number of buffers. (Fixes: :issue:`1147`,
maximum 100ms instead of a fixed number of buffers. (Addresses: :issue:`1147`,
PR: :issue:`1154`)
@ -83,7 +108,7 @@ Bug fix release.
- Audio: Fix for :issue:`1097` tuned down the buffer size in the queue. Turns
out this can cause distortions in certain cases. Give this an other go with
a more generous buffer size. (Fixes: :issue:`1147`, PR: :issue:`1152`)
a more generous buffer size. (Addresses: :issue:`1147`, PR: :issue:`1152`)
- Audio: Make sure mute events get emitted by software mixer.
(Fixes: :issue:`1146`, PR: :issue:`1152`)

View File

@ -47,7 +47,6 @@ class Mock(object):
return Mock()
MOCK_MODULES = [
'cherrypy',
'dbus',
'dbus.mainloop',
'dbus.mainloop.glib',
@ -61,12 +60,6 @@ MOCK_MODULES = [
'pykka.actor',
'pykka.future',
'pykka.registry',
'pylast',
'ws4py',
'ws4py.messaging',
'ws4py.server',
'ws4py.server.cherrypyserver',
'ws4py.websocket',
]
for mod_name in MOCK_MODULES:
sys.modules[mod_name] = Mock()
@ -102,7 +95,7 @@ master_doc = 'index'
project = 'Mopidy'
copyright = '2009-2015, Stein Magnus Jodal and contributors'
from mopidy.utils.versioning import get_version
from mopidy.internal.versioning import get_version
release = get_version()
version = '.'.join(release.split('.')[:2])

View File

@ -434,8 +434,8 @@ Use of Mopidy APIs
==================
When writing an extension, you should only use APIs documented at
:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils`, may change at
any time and are not something extensions should use.
:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.internal`, may change
at any time and are not something extensions should use.
Logging in extensions

View File

@ -1,9 +1,23 @@
************************************
:mod:`mopidy.local` -- Local backend
************************************
*************************************
:mod:`mopidy.local` --- Local backend
*************************************
For details on how to use Mopidy's local backend, see :ref:`ext-local`.
.. automodule:: mopidy.local
:synopsis: Local backend
Local library API
=================
.. autoclass:: mopidy.local.Library
:members:
Translation utils
=================
.. automodule:: mopidy.local.translator
:synopsis: Translators for local library extensions
:members:

View File

@ -1,6 +1,6 @@
*******************************
:mod:`mopidy.mpd` -- MPD server
*******************************
********************************
:mod:`mopidy.mpd` --- MPD server
********************************
For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`.

View File

@ -14,4 +14,4 @@ if not (2, 7) <= sys.version_info < (3,):
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '1.0.3'
__version__ = '1.0.4'

View File

@ -41,7 +41,7 @@ sys.argv[1:] = []
from mopidy import commands, config as config_lib, ext
from mopidy.utils import encoding, log, path, process, versioning
from mopidy.internal import encoding, log, path, process, versioning
logger = logging.getLogger(__name__)
@ -137,7 +137,7 @@ def main():
extension.setup(registry)
# Anything that wants to exit after this point must use
# mopidy.utils.process.exit_process as actors can have been started.
# mopidy.internal.process.exit_process as actors can have been started.
try:
return args.command.run(args, proxied_config)
except NotImplementedError:

View File

@ -16,7 +16,7 @@ from mopidy import exceptions
from mopidy.audio import playlists, utils
from mopidy.audio.constants import PlaybackState
from mopidy.audio.listener import AudioListener
from mopidy.utils import deprecation, process
from mopidy.internal import deprecation, process
logger = logging.getLogger(__name__)
@ -166,11 +166,7 @@ class _Outputs(gst.Bin):
logger.info('Audio output set to "%s"', description)
def _add(self, element):
# All tee branches need a queue in front of them.
# But keep the queue short so the volume change isn't to slow:
queue = gst.element_factory_make('queue')
queue.set_property('max-size-time', 100 * gst.MSECOND)
self.add(element)
self.add(queue)
queue.link(element)

View File

@ -10,7 +10,7 @@ import gst.pbutils # noqa
from mopidy import exceptions
from mopidy.audio import utils
from mopidy.utils import encoding
from mopidy.internal import encoding
_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description
@ -182,7 +182,7 @@ if __name__ == '__main__':
import gobject
from mopidy.utils import path
from mopidy.internal import path
gobject.threads_init()

View File

@ -13,7 +13,7 @@ import gobject
from mopidy import config as config_lib, exceptions
from mopidy.audio import Audio
from mopidy.core import Core
from mopidy.utils import deps, process, timer, versioning
from mopidy.internal import deps, process, timer, versioning
logger = logging.getLogger(__name__)

View File

@ -11,7 +11,7 @@ from mopidy.compat import configparser
from mopidy.config import keyring
from mopidy.config.schemas import * # noqa
from mopidy.config.types import * # noqa
from mopidy.utils import path, versioning
from mopidy.internal import path, versioning
logger = logging.getLogger(__name__)

View File

@ -6,7 +6,7 @@ import socket
from mopidy import compat
from mopidy.config import validators
from mopidy.utils import log, path
from mopidy.internal import log, path
def decode(value):

View File

@ -14,8 +14,8 @@ from mopidy.core.mixer import MixerController
from mopidy.core.playback import PlaybackController
from mopidy.core.playlists import PlaylistsController
from mopidy.core.tracklist import TracklistController
from mopidy.utils import versioning
from mopidy.utils.deprecation import deprecated_property
from mopidy.internal import versioning
from mopidy.internal.deprecation import deprecated_property
class Core(

View File

@ -1,16 +1,32 @@
from __future__ import absolute_import, unicode_literals
import collections
import contextlib
import logging
import operator
import urlparse
from mopidy.utils import deprecation, validation
from mopidy import compat, exceptions, models
from mopidy.internal import deprecation, validation
logger = logging.getLogger(__name__)
@contextlib.contextmanager
def _backend_error_handling(backend, reraise=None):
try:
yield
except exceptions.ValidationError as e:
logger.error('%s backend returned bad data: %s',
backend.actor_ref.actor_class.__name__, e)
except Exception as e:
if reraise and isinstance(e, reraise):
raise
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
class LibraryController(object):
pykka_traversable = True
@ -79,22 +95,24 @@ class LibraryController(object):
backends = self.backends.with_library_browse.values()
futures = {b: b.library.root_directory for b in backends}
for backend, future in futures.items():
try:
directories.add(future.get())
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
with _backend_error_handling(backend):
root = future.get()
validation.check_instance(root, models.Ref)
directories.add(root)
return sorted(directories, key=operator.attrgetter('name'))
def _browse(self, uri):
scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_library_browse.get(scheme)
try:
if backend:
return backend.library.browse(uri).get()
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
if not backend:
return []
with _backend_error_handling(backend):
result = backend.library.browse(uri).get()
validation.check_instances(result, models.Ref)
return result
return []
def get_distinct(self, field, query=None):
@ -106,7 +124,7 @@ class LibraryController(object):
recommended to use this method.
:param string field: One of ``artist``, ``albumartist``, ``album``,
``composer``, ``performer``, ``date``or ``genre``.
``composer``, ``performer``, ``date`` or ``genre``.
:param dict query: Query to use for limiting results, see
:meth:`search` for details about the query format.
:rtype: set of values corresponding to the requested field type.
@ -120,11 +138,11 @@ class LibraryController(object):
futures = {b: b.library.get_distinct(field, query)
for b in self.backends.with_library.values()}
for backend, future in futures.items():
try:
result.update(future.get())
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
with _backend_error_handling(backend):
values = future.get()
if values is not None:
validation.check_instances(values, compat.text_type)
result.update(values)
return result
def get_images(self, uris):
@ -152,12 +170,16 @@ class LibraryController(object):
results = {uri: tuple() for uri in uris}
for backend, future in futures.items():
try:
with _backend_error_handling(backend):
if future.get() is None:
continue
validation.check_instance(future.get(), collections.Mapping)
for uri, images in future.get().items():
if uri not in uris:
raise exceptions.ValidationError(
'Got unknown image URI: %s' % uri)
validation.check_instances(images, models.Image)
results[uri] += tuple(images)
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
return results
def find_exact(self, query=None, uris=None, **kwargs):
@ -202,7 +224,7 @@ class LibraryController(object):
uris = [uri]
futures = {}
result = {u: [] for u in uris}
results = {u: [] for u in uris}
# TODO: lookup(uris) to backend APIs
for backend, backend_uris in self._get_backends_to_uris(uris).items():
@ -210,15 +232,15 @@ class LibraryController(object):
futures[(backend, u)] = backend.library.lookup(u)
for (backend, u), future in futures.items():
try:
result[u] = future.get()
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
with _backend_error_handling(backend):
result = future.get()
if result is not None:
validation.check_instances(result, models.Track)
results[u] = result
if uri:
return result[uri]
return result
return results[uri]
return results
def refresh(self, uri=None):
"""
@ -241,11 +263,8 @@ class LibraryController(object):
futures[backend] = backend.library.refresh(uri)
for backend, future in futures.items():
try:
with _backend_error_handling(backend):
future.get()
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
def search(self, query=None, uris=None, exact=False, **kwargs):
"""
@ -311,25 +330,26 @@ class LibraryController(object):
futures[backend] = backend.library.search(
query=query, uris=backend_uris, exact=exact)
# Some of our tests check for LookupError to catch bad queries. This is
# silly and should be replaced with query validation before passing it
# to the backends.
reraise = (TypeError, LookupError)
results = []
for backend, future in futures.items():
try:
results.append(future.get())
with _backend_error_handling(backend, reraise=reraise):
result = future.get()
if result is not None:
validation.check_instance(result, models.SearchResult)
results.append(result)
except TypeError:
backend_name = backend.actor_ref.actor_class.__name__
logger.warning(
'%s does not implement library.search() with "exact" '
'support. Please upgrade it.', backend_name)
except LookupError:
# Some of our tests check for this to catch bad queries. This
# is silly and should be replaced with query validation before
# passing it to the backends.
raise
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
return [r for r in results if r]
return results
def _normalize_query(query):

View File

@ -1,13 +1,27 @@
from __future__ import absolute_import, unicode_literals
import contextlib
import logging
from mopidy.utils import validation
from mopidy import exceptions
from mopidy.internal import validation
logger = logging.getLogger(__name__)
@contextlib.contextmanager
def _mixer_error_handling(mixer):
try:
yield
except exceptions.ValidationError as e:
logger.error('%s mixer returned bad data: %s',
mixer.actor_ref.actor_class.__name__, e)
except Exception:
logger.exception('%s mixer caused an exception.',
mixer.actor_ref.actor_class.__name__)
class MixerController(object):
pykka_traversable = True
@ -21,8 +35,15 @@ class MixerController(object):
The volume scale is linear.
"""
if self._mixer is not None:
return self._mixer.get_volume().get()
if self._mixer is None:
return None
with _mixer_error_handling(self._mixer):
volume = self._mixer.get_volume().get()
volume is None or validation.check_integer(volume, min=0, max=100)
return volume
return None
def set_volume(self, volume):
"""Set the volume.
@ -36,9 +57,14 @@ class MixerController(object):
validation.check_integer(volume, min=0, max=100)
if self._mixer is None:
return False
else:
return self._mixer.set_volume(volume).get()
return False # TODO: 2.0 return None
with _mixer_error_handling(self._mixer):
result = self._mixer.set_volume(volume).get()
validation.check_instance(result, bool)
return result
return False
def get_mute(self):
"""Get mute state.
@ -46,8 +72,15 @@ class MixerController(object):
:class:`True` if muted, :class:`False` unmuted, :class:`None` if
unknown.
"""
if self._mixer is not None:
return self._mixer.get_mute().get()
if self._mixer is None:
return None
with _mixer_error_handling(self._mixer):
mute = self._mixer.get_mute().get()
mute is None or validation.check_instance(mute, bool)
return mute
return None
def set_mute(self, mute):
"""Set mute state.
@ -58,6 +91,11 @@ class MixerController(object):
"""
validation.check_boolean(mute)
if self._mixer is None:
return False
else:
return self._mixer.set_mute(bool(mute)).get()
return False # TODO: 2.0 return None
with _mixer_error_handling(self._mixer):
result = self._mixer.set_mute(bool(mute)).get()
validation.check_instance(result, bool)
return result
return False

View File

@ -6,7 +6,7 @@ import urlparse
from mopidy import models
from mopidy.audio import PlaybackState
from mopidy.core import listener
from mopidy.utils import deprecation, validation
from mopidy.internal import deprecation, validation
logger = logging.getLogger(__name__)
@ -61,9 +61,7 @@ class PlaybackController(object):
Returns a :class:`mopidy.models.Track` or :class:`None`.
"""
tl_track = self.get_current_tl_track()
if tl_track is not None:
return tl_track.track
return getattr(self.get_current_tl_track(), 'track', None)
current_track = deprecation.deprecated_property(get_current_track)
"""
@ -71,6 +69,18 @@ class PlaybackController(object):
Use :meth:`get_current_track` instead.
"""
def get_current_tlid(self):
"""
Get the currently playing or selected TLID.
Extracted from :meth:`get_current_tl_track` for convenience.
Returns a :class:`int` or :class:`None`.
.. versionadded:: 1.1
"""
return getattr(self.get_current_tl_track(), 'tlid', None)
def get_stream_title(self):
"""Get the current stream title or :class:`None`."""
return self._stream_title

View File

@ -1,15 +1,31 @@
from __future__ import absolute_import, unicode_literals
import contextlib
import logging
import urlparse
from mopidy import exceptions
from mopidy.core import listener
from mopidy.models import Playlist
from mopidy.utils import deprecation, validation
from mopidy.internal import deprecation, validation
from mopidy.models import Playlist, Ref
logger = logging.getLogger(__name__)
@contextlib.contextmanager
def _backend_error_handling(backend, reraise=None):
try:
yield
except exceptions.ValidationError as e:
logger.error('%s backend returned bad data: %s',
backend.actor_ref.actor_class.__name__, e)
except Exception as e:
if reraise and isinstance(e, reraise):
raise
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
class PlaylistsController(object):
pykka_traversable = True
@ -34,17 +50,18 @@ class PlaylistsController(object):
for backend in set(self.backends.with_playlists.values())}
results = []
for backend, future in futures.items():
for b, future in futures.items():
try:
results.extend(future.get())
with _backend_error_handling(b, reraise=NotImplementedError):
playlists = future.get()
if playlists is not None:
validation.check_instances(playlists, Ref)
results.extend(playlists)
except NotImplementedError:
backend_name = backend.actor_ref.actor_class.__name__
backend_name = b.actor_ref.actor_class.__name__
logger.warning(
'%s does not implement playlists.as_list(). '
'Please upgrade it.', backend_name)
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
return results
@ -66,8 +83,16 @@ class PlaylistsController(object):
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
return backend.playlists.get_items(uri).get()
if not backend:
return None
with _backend_error_handling(backend):
items = backend.playlists.get_items(uri).get()
items is None or validation.check_instances(items, Ref)
return items
return None
def get_playlists(self, include_tracks=True):
"""
@ -120,16 +145,23 @@ class PlaylistsController(object):
:type name: string
:param uri_scheme: use the backend matching the URI scheme
:type uri_scheme: string
:rtype: :class:`mopidy.models.Playlist`
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
if uri_scheme in self.backends.with_playlists:
backend = self.backends.with_playlists[uri_scheme]
backends = [self.backends.with_playlists[uri_scheme]]
else:
# TODO: this fallback looks suspicious
backend = list(self.backends.with_playlists.values())[0]
playlist = backend.playlists.create(name).get()
listener.CoreListener.send('playlist_changed', playlist=playlist)
return playlist
backends = self.backends.with_playlists.values()
for backend in backends:
with _backend_error_handling(backend):
result = backend.playlists.create(name).get()
if result is None:
continue
validation.check_instance(result, Playlist)
listener.CoreListener.send('playlist_changed', playlist=result)
return result
return None
def delete(self, uri):
"""
@ -145,8 +177,14 @@ class PlaylistsController(object):
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
if not backend:
return
with _backend_error_handling(backend):
backend.playlists.delete(uri).get()
# TODO: emit playlist changed?
# TODO: return value?
def filter(self, criteria=None, **kwargs):
"""
@ -192,11 +230,16 @@ class PlaylistsController(object):
"""
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
return backend.playlists.lookup(uri).get()
else:
if not backend:
return None
with _backend_error_handling(backend):
playlist = backend.playlists.lookup(uri).get()
playlist is None or validation.check_instance(playlist, Playlist)
return playlist
return None
# TODO: there is an inconsistency between library.refresh(uri) and this
# call, not sure how to sort this out.
def refresh(self, uri_scheme=None):
@ -225,12 +268,9 @@ class PlaylistsController(object):
futures[backend] = backend.playlists.refresh()
for backend, future in futures.items():
try:
with _backend_error_handling(backend):
future.get()
playlists_loaded = True
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
if playlists_loaded:
listener.CoreListener.send('playlists_loaded')
@ -264,7 +304,16 @@ class PlaylistsController(object):
uri_scheme = urlparse.urlparse(playlist.uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
if not backend:
return None
# TODO: we let AssertionError error through due to legacy tests :/
with _backend_error_handling(backend, reraise=AssertionError):
playlist = backend.playlists.save(playlist).get()
listener.CoreListener.send('playlist_changed', playlist=playlist)
playlist is None or validation.check_instance(playlist, Playlist)
if playlist:
listener.CoreListener.send(
'playlist_changed', playlist=playlist)
return playlist
return None

View File

@ -4,8 +4,8 @@ import logging
import random
from mopidy.core import listener
from mopidy.internal import deprecation, validation
from mopidy.models import TlTrack, Track
from mopidy.utils import deprecation, validation
logger = logging.getLogger(__name__)

View File

@ -16,7 +16,7 @@ import tornado.websocket
from mopidy import exceptions, models, zeroconf
from mopidy.core import CoreListener
from mopidy.http import handlers
from mopidy.utils import encoding, formatting, network
from mopidy.internal import encoding, formatting, network
logger = logging.getLogger(__name__)

View File

@ -12,7 +12,7 @@ import tornado.websocket
import mopidy
from mopidy import core, models
from mopidy.utils import encoding, jsonrpc
from mopidy.internal import encoding, jsonrpc
logger = logging.getLogger(__name__)

View File

@ -11,7 +11,7 @@ import pygst
pygst.require('0.10')
import gst # noqa
from mopidy.utils import formatting
from mopidy.internal import formatting
def format_dependency_list(adapters=None):

View File

@ -11,7 +11,7 @@ import gobject
import pykka
from mopidy.utils import encoding
from mopidy.internal import encoding
logger = logging.getLogger(__name__)

View File

@ -10,7 +10,7 @@ import urlparse
from mopidy import compat, exceptions
from mopidy.compat import queue
from mopidy.utils import encoding, xdg
from mopidy.internal import encoding, xdg
logger = logging.getLogger(__name__)

View File

@ -7,8 +7,8 @@ import time
from mopidy import commands, compat, exceptions
from mopidy.audio import scan, utils
from mopidy.internal import path
from mopidy.local import translator
from mopidy.utils import path
logger = logging.getLogger(__name__)

View File

@ -11,8 +11,8 @@ import tempfile
import mopidy
from mopidy import compat, local, models
from mopidy.internal import encoding, timer
from mopidy.local import search, storage, translator
from mopidy.utils import encoding, timer
logger = logging.getLogger(__name__)

View File

@ -7,5 +7,5 @@ from mopidy.local import translator
class LocalPlaybackProvider(backend.PlaybackProvider):
def translate_uri(self, uri):
return translator.local_track_uri_to_file_uri(
return translator.local_uri_to_file_uri(
uri, self.backend.config['local']['media_dir'])

View File

@ -3,7 +3,7 @@ from __future__ import absolute_import, unicode_literals
import logging
import os
from mopidy.utils import encoding, path
from mopidy.internal import encoding, path
logger = logging.getLogger(__name__)

View File

@ -5,25 +5,41 @@ import os
import urllib
from mopidy import compat
from mopidy.utils.path import path_to_uri, uri_to_path
from mopidy.internal import path
logger = logging.getLogger(__name__)
def local_track_uri_to_file_uri(uri, media_dir):
return path_to_uri(local_track_uri_to_path(uri, media_dir))
def local_uri_to_file_uri(uri, media_dir):
"""Convert local track or directory URI to file URI."""
return path_to_file_uri(local_uri_to_path(uri, media_dir))
def local_track_uri_to_path(uri, media_dir):
if not uri.startswith('local:track:'):
def local_uri_to_path(uri, media_dir):
"""Convert local track or directory URI to absolute path."""
if (
not uri.startswith('local:directory:') and
not uri.startswith('local:track:')):
raise ValueError('Invalid URI.')
file_path = uri_to_path(uri).split(b':', 1)[1]
file_path = path.uri_to_path(uri).split(b':', 1)[1]
return os.path.join(media_dir, file_path)
def local_track_uri_to_path(uri, media_dir):
# Deprecated version to keep old versions of Mopidy-Local-Sqlite working.
return local_uri_to_path(uri, media_dir)
def path_to_file_uri(abspath):
"""Convert absolute path to file URI."""
# Re-export internal method for use by Mopidy-Local-* extensions.
return path.path_to_uri(abspath)
def path_to_local_track_uri(relpath):
"""Convert path relative to media_dir to local track URI."""
"""Convert path relative to :confval:`local/media_dir` to local track
URI."""
if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8')
return b'local:track:%s' % urllib.quote(relpath)

View File

@ -5,9 +5,9 @@ import logging
import pykka
from mopidy import backend
from mopidy.internal import encoding, path
from mopidy.m3u.library import M3ULibraryProvider
from mopidy.m3u.playlists import M3UPlaylistsProvider
from mopidy.utils import encoding, path
logger = logging.getLogger(__name__)

View File

@ -7,9 +7,8 @@ import urllib
import urlparse
from mopidy import compat
from mopidy.internal import encoding, path
from mopidy.models import Track
from mopidy.utils.encoding import locale_decode
from mopidy.utils.path import path_to_uri, uri_to_path
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)')
@ -20,7 +19,7 @@ logger = logging.getLogger(__name__)
def playlist_uri_to_path(uri, playlists_dir):
if not uri.startswith('m3u:'):
raise ValueError('Invalid URI %s' % uri)
file_path = uri_to_path(uri)
file_path = path.uri_to_path(uri)
return os.path.join(playlists_dir, file_path)
@ -80,7 +79,7 @@ def parse_m3u(file_path, media_dir=None):
with open(file_path) as m3u:
contents = m3u.readlines()
except IOError as error:
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
logger.warning('Couldn\'t open m3u: %s', encoding.locale_decode(error))
return tracks
if not contents:
@ -100,11 +99,11 @@ def parse_m3u(file_path, media_dir=None):
if urlparse.urlsplit(line).scheme:
tracks.append(track.replace(uri=line))
elif os.path.normpath(line) == os.path.abspath(line):
path = path_to_uri(line)
tracks.append(track.replace(uri=path))
uri = path.path_to_uri(line)
tracks.append(track.replace(uri=uri))
elif media_dir is not None:
path = path_to_uri(os.path.join(media_dir, line))
tracks.append(track.replace(uri=path))
uri = path.path_to_uri(os.path.join(media_dir, line))
tracks.append(track.replace(uri=uri))
track = Track()
return tracks

View File

@ -1,15 +1,16 @@
from __future__ import absolute_import, unicode_literals
from mopidy.models import fields
from mopidy.models.immutable import ImmutableObject
from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject
from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder
__all__ = [
'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack',
'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder']
'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder',
'ValidatedImmutableObject']
class Ref(ImmutableObject):
class Ref(ValidatedImmutableObject):
"""
Model to represent URI references with a human friendly name and type
@ -81,7 +82,7 @@ class Ref(ImmutableObject):
return cls(**kwargs)
class Image(ImmutableObject):
class Image(ValidatedImmutableObject):
"""
:param string uri: URI of the image
@ -99,7 +100,7 @@ class Image(ImmutableObject):
height = fields.Integer(min=0)
class Artist(ImmutableObject):
class Artist(ValidatedImmutableObject):
"""
:param uri: artist URI
@ -120,7 +121,7 @@ class Artist(ImmutableObject):
musicbrainz_id = fields.Identifier()
class Album(ImmutableObject):
class Album(ValidatedImmutableObject):
"""
:param uri: album URI
@ -169,7 +170,7 @@ class Album(ImmutableObject):
# actual usage of this field with more than one image.
class Track(ImmutableObject):
class Track(ValidatedImmutableObject):
"""
:param uri: track URI
@ -253,7 +254,7 @@ class Track(ImmutableObject):
last_modified = fields.Integer(min=0)
class TlTrack(ImmutableObject):
class TlTrack(ValidatedImmutableObject):
"""
A tracklist track. Wraps a regular track and it's tracklist ID.
@ -292,7 +293,7 @@ class TlTrack(ImmutableObject):
return iter([self.tlid, self.track])
class Playlist(ImmutableObject):
class Playlist(ValidatedImmutableObject):
"""
:param uri: playlist URI
@ -329,7 +330,7 @@ class Playlist(ImmutableObject):
return len(self.tracks)
class SearchResult(ImmutableObject):
class SearchResult(ValidatedImmutableObject):
"""
:param uri: search result URI

View File

@ -4,8 +4,9 @@ from __future__ import absolute_import, unicode_literals
class Field(object):
"""
Base field for use in :class:`ImmutableObject`. These fields are
responsible for type checking and other data sanitation in our models.
Base field for use in
:class:`~mopidy.models.immutable.ValidatedImmutableObject`. These fields
are responsible for type checking and other data sanitation in our models.
For simplicity fields use the Python descriptor protocol to store the
values in the instance dictionary. Also note that fields are mutable if
@ -19,7 +20,7 @@ class Field(object):
"""
def __init__(self, default=None, type=None, choices=None):
self._name = None # Set by ImmutableObjectMeta
self._name = None # Set by ValidatedImmutableObjectMeta
self._choices = choices
self._default = default
self._type = type
@ -72,20 +73,41 @@ class String(Field):
class Date(String):
"""
:class:`Field` for storing ISO 8601 dates as a string.
Supported formats are ``YYYY-MM-DD``, ``YYYY-MM`` and ``YYYY``, currently
not validated.
:param default: default value for field
"""
pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using strptime.
class Identifier(String):
"""
:class:`Field` for storing ASCII values such as GUIDs or other identifiers.
Values will be interned.
:param default: default value for field
"""
def validate(self, value):
return intern(str(super(Identifier, self).validate(value)))
class URI(Identifier):
"""
:class`Field` for storing URIs
Values will be interned, currently not validated.
:param default: default value for field
"""
pass # TODO: validate URIs?
class Integer(Field):
"""
:class:`Field` for storing integer numbers.
@ -111,7 +133,6 @@ class Integer(Field):
class Collection(Field):
"""
:class:`Field` for storing collections of a given type.

View File

@ -1,81 +1,61 @@
from __future__ import absolute_import, unicode_literals
import copy
import inspect
import itertools
import weakref
from mopidy.internal import deprecation
from mopidy.models.fields import Field
from mopidy.utils import deprecation
class ImmutableObjectMeta(type):
"""Helper to automatically assign field names to descriptors."""
def __new__(cls, name, bases, attrs):
fields = {}
for key, value in attrs.items():
if isinstance(value, Field):
fields[key] = '_' + key
value._name = key
attrs['_fields'] = fields
attrs['_instances'] = weakref.WeakValueDictionary()
attrs['__slots__'] = ['_hash'] + fields.values()
for ancestor in [b for base in bases for b in inspect.getmro(base)]:
if '__weakref__' in getattr(ancestor, '__slots__', []):
break
else:
attrs['__slots__'].append('__weakref__')
return super(ImmutableObjectMeta, cls).__new__(cls, name, bases, attrs)
def __call__(cls, *args, **kwargs): # noqa: N805
instance = super(ImmutableObjectMeta, cls).__call__(*args, **kwargs)
return cls._instances.setdefault(weakref.ref(instance), instance)
class ImmutableObject(object):
"""
Superclass for immutable objects whose fields can only be modified via the
constructor. Fields should be :class:`Field` instances to ensure type
safety in our models.
constructor.
Note that since these models can not be changed, we heavily memoize them
to save memory. So constructing a class with the same arguments twice will
give you the same instance twice.
This version of this class has been retained to avoid breaking any clients
relying on it's behavior. Internally in Mopidy we now use
:class:`ValidatedImmutableObject` for type safety and it's much smaller
memory footprint.
:param kwargs: kwargs to set as fields on the object
:type kwargs: any
"""
__metaclass__ = ImmutableObjectMeta
# Any sub-classes that don't set slots won't be effected by the base using
# slots as they will still get an instance dict.
__slots__ = ['__weakref__']
def __init__(self, *args, **kwargs):
for key, value in kwargs.items():
if key not in self._fields:
if not self._is_valid_field(key):
raise TypeError(
'__init__() got an unexpected keyword argument "%s"' %
key)
super(ImmutableObject, self).__setattr__(key, value)
'__init__() got an unexpected keyword argument "%s"' % key)
self._set_field(key, value)
def __setattr__(self, name, value):
if name in self.__slots__:
return super(ImmutableObject, self).__setattr__(name, value)
raise AttributeError('Object is immutable.')
if name.startswith('_'):
object.__setattr__(self, name, value)
else:
raise AttributeError('Object is immutable.')
def __delattr__(self, name):
if name in self.__slots__:
return super(ImmutableObject, self).__delattr__(name)
raise AttributeError('Object is immutable.')
if name.startswith('_'):
object.__delattr__(self, name)
else:
raise AttributeError('Object is immutable.')
def _is_valid_field(self, name):
return hasattr(self, name) and not callable(getattr(self, name))
def _set_field(self, name, value):
if value == getattr(self.__class__, name):
self.__dict__.pop(name, None)
else:
self.__dict__[name] = value
def _items(self):
for field, key in self._fields.items():
if hasattr(self, key):
yield field, getattr(self, key)
return self.__dict__.iteritems()
def __repr__(self):
kwarg_pairs = []
@ -91,12 +71,10 @@ class ImmutableObject(object):
}
def __hash__(self):
if not hasattr(self, '_hash'):
hash_sum = 0
for key, value in self._items():
hash_sum += hash(key) + hash(value)
super(ImmutableObject, self).__setattr__('_hash', hash_sum)
return self._hash
hash_sum = 0
for key, value in self._items():
hash_sum += hash(key) + hash(value)
return hash_sum
def __eq__(self, other):
if not isinstance(other, self.__class__):
@ -110,11 +88,108 @@ class ImmutableObject(object):
def copy(self, **values):
"""
.. deprecated:: 1.1
Use :meth:`replace` instead. Note that we no longer return copies.
Use :meth:`replace` instead.
"""
deprecation.warn('model.immutable.copy')
return self.replace(**values)
def replace(self, **kwargs):
"""
Replace the fields in the model and return a new instance
Examples::
# Returns a track with a new name
Track(name='foo').replace(name='bar')
# Return an album with a new number of tracks
Album(num_tracks=2).replace(num_tracks=5)
:param kwargs: kwargs to set as fields on the object
:type kwargs: any
:rtype: instance of the model with replaced fields
"""
other = copy.copy(self)
for key, value in kwargs.items():
if not self._is_valid_field(key):
raise TypeError(
'copy() got an unexpected keyword argument "%s"' % key)
other._set_field(key, value)
return other
def serialize(self):
data = {}
data['__model__'] = self.__class__.__name__
for key, value in self._items():
if isinstance(value, (set, frozenset, list, tuple)):
value = [
v.serialize() if isinstance(v, ImmutableObject) else v
for v in value]
elif isinstance(value, ImmutableObject):
value = value.serialize()
if not (isinstance(value, list) and len(value) == 0):
data[key] = value
return data
class _ValidatedImmutableObjectMeta(type):
"""Helper that initializes fields, slots and memoizes instance creation."""
def __new__(cls, name, bases, attrs):
fields = {}
for base in bases: # Copy parent fields over to our state
fields.update(getattr(base, '_fields', {}))
for key, value in attrs.items(): # Add our own fields
if isinstance(value, Field):
fields[key] = '_' + key
value._name = key
attrs['_fields'] = fields
attrs['_instances'] = weakref.WeakValueDictionary()
attrs['__slots__'] = list(attrs.get('__slots__', [])) + fields.values()
return super(_ValidatedImmutableObjectMeta, cls).__new__(
cls, name, bases, attrs)
def __call__(cls, *args, **kwargs): # noqa: N805
instance = super(_ValidatedImmutableObjectMeta, cls).__call__(
*args, **kwargs)
return cls._instances.setdefault(weakref.ref(instance), instance)
class ValidatedImmutableObject(ImmutableObject):
"""
Superclass for immutable objects whose fields can only be modified via the
constructor. Fields should be :class:`Field` instances to ensure type
safety in our models.
Note that since these models can not be changed, we heavily memoize them
to save memory. So constructing a class with the same arguments twice will
give you the same instance twice.
"""
__metaclass__ = _ValidatedImmutableObjectMeta
__slots__ = ['_hash']
def __hash__(self):
if not hasattr(self, '_hash'):
hash_sum = super(ValidatedImmutableObject, self).__hash__()
object.__setattr__(self, '_hash', hash_sum)
return self._hash
def _is_valid_field(self, name):
return name in self._fields
def _set_field(self, name, value):
object.__setattr__(self, name, value)
def _items(self):
for field, key in self._fields.items():
if hasattr(self, key):
yield field, getattr(self, key)
def replace(self, **kwargs):
"""
Replace the fields in the model and return a new instance
@ -136,25 +211,7 @@ class ImmutableObject(object):
"""
if not kwargs:
return self
other = copy.copy(self)
for key, value in kwargs.items():
if key not in self._fields:
raise TypeError(
'copy() got an unexpected keyword argument "%s"' % key)
super(ImmutableObject, other).__setattr__(key, value)
super(ImmutableObject, other).__delattr__('_hash')
other = super(ValidatedImmutableObject, self).replace(**kwargs)
if hasattr(self, '_hash'):
object.__delattr__(other, '_hash')
return self._instances.setdefault(weakref.ref(other), other)
def serialize(self):
data = {}
data['__model__'] = self.__class__.__name__
for key, value in self._items():
if isinstance(value, (set, frozenset, list, tuple)):
value = [
v.serialize() if isinstance(v, ImmutableObject) else v
for v in value]
elif isinstance(value, ImmutableObject):
value = value.serialize()
if not (isinstance(value, list) and len(value) == 0):
data[key] = value
return data

View File

@ -2,7 +2,9 @@ from __future__ import absolute_import, unicode_literals
import json
from mopidy.models.immutable import ImmutableObject
from mopidy.models import immutable
_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist']
class ModelJSONEncoder(json.JSONEncoder):
@ -19,7 +21,7 @@ class ModelJSONEncoder(json.JSONEncoder):
"""
def default(self, obj):
if isinstance(obj, ImmutableObject):
if isinstance(obj, immutable.ImmutableObject):
return obj.serialize()
return json.JSONEncoder.default(self, obj)
@ -38,8 +40,8 @@ def model_json_decoder(dct):
"""
if '__model__' in dct:
models = {c.__name__: c for c in ImmutableObject.__subclasses__()}
from mopidy import models
model_name = dct.pop('__model__')
if model_name in models:
return models[model_name](**dct)
if model_name in _MODELS:
return getattr(models, model_name)(**dct)
return dct

View File

@ -6,8 +6,8 @@ import pykka
from mopidy import exceptions, zeroconf
from mopidy.core import CoreListener
from mopidy.internal import encoding, network, process
from mopidy.mpd import session, uri_mapper
from mopidy.utils import encoding, network, process
logger = logging.getLogger(__name__)

View File

@ -167,7 +167,8 @@ class MpdDispatcher(object):
# TODO: check that blacklist items are valid commands?
blacklist = self.config['mpd'].get('command_blacklist', [])
if tokens and tokens[0] in blacklist:
logger.warning('Client sent us blacklisted command: %s', tokens[0])
logger.warning(
'MPD client used blacklisted command: %s', tokens[0])
raise exceptions.MpdDisabled(command=tokens[0])
try:
return protocol.commands.call(tokens, context=self.context)

View File

@ -2,8 +2,8 @@ from __future__ import absolute_import, unicode_literals
import urlparse
from mopidy.internal import deprecation
from mopidy.mpd import exceptions, protocol, translator
from mopidy.utils import deprecation
@protocol.commands.add('add')

View File

@ -3,9 +3,9 @@ from __future__ import absolute_import, unicode_literals
import functools
import itertools
from mopidy.internal import deprecation
from mopidy.models import Track
from mopidy.mpd import exceptions, protocol, translator
from mopidy.utils import deprecation
_SEARCH_MAPPING = {
'album': 'album',

View File

@ -1,8 +1,8 @@
from __future__ import absolute_import, unicode_literals
from mopidy.core import PlaybackState
from mopidy.internal import deprecation
from mopidy.mpd import exceptions, protocol
from mopidy.utils import deprecation
@protocol.commands.add('consume', state=protocol.BOOL)

View File

@ -2,8 +2,8 @@ from __future__ import absolute_import, unicode_literals
import logging
from mopidy.internal import formatting, network
from mopidy.mpd import dispatcher, protocol
from mopidy.utils import formatting, network
logger = logging.getLogger(__name__)

View File

@ -16,7 +16,7 @@ import pykka
from mopidy import audio
from mopidy.audio.constants import PlaybackState
from mopidy.utils.path import path_to_uri
from mopidy.internal import path
from tests import dummy_audio, path_to_data_dir
@ -36,8 +36,8 @@ class BaseTest(unittest.TestCase):
}
}
uris = [path_to_uri(path_to_data_dir('song1.wav')),
path_to_uri(path_to_data_dir('song2.wav'))]
uris = [path.path_to_uri(path_to_data_dir('song1.wav')),
path.path_to_uri(path_to_data_dir('song2.wav'))]
audio_class = audio.Audio
@ -53,7 +53,7 @@ class BaseTest(unittest.TestCase):
'hostname': '',
},
}
self.song_uri = path_to_uri(path_to_data_dir('song1.wav'))
self.song_uri = path.path_to_uri(path_to_data_dir('song1.wav'))
self.audio = self.audio_class.start(config=config, mixer=None).proxy()
def tearDown(self): # noqa

View File

@ -8,7 +8,7 @@ gobject.threads_init()
from mopidy import exceptions
from mopidy.audio import scan
from mopidy.utils import path as path_lib
from mopidy.internal import path as path_lib
from tests import path_to_data_dir

View File

@ -373,7 +373,7 @@ class ExpandedPathTest(unittest.TestCase):
expanded = b'expanded_path'
self.assertEqual(expanded, types.ExpandedPath(original, expanded))
@mock.patch('mopidy.utils.path.expand_path')
@mock.patch('mopidy.internal.path.expand_path')
def test_orginal_stores_unexpanded(self, expand_path_mock):
original = b'~'
expanded = b'expanded_path'

View File

@ -7,7 +7,7 @@ import mock
import pykka
from mopidy.core import Core
from mopidy.utils import versioning
from mopidy.internal import versioning
class CoreActorTest(unittest.TestCase):

View File

@ -7,8 +7,8 @@ import mock
import pykka
from mopidy import core
from mopidy.internal import deprecation
from mopidy.models import Track
from mopidy.utils import deprecation
from tests import dummy_backend
@ -45,15 +45,12 @@ class BackendEventsTest(unittest.TestCase):
self.assertEqual(send.call_args[1]['mute'], True)
def test_tracklist_add_sends_tracklist_changed_event(self, send):
send.reset_mock()
self.core.tracklist.add(uris=['dummy:a']).get()
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
def test_tracklist_clear_sends_tracklist_changed_event(self, send):
self.core.tracklist.add(uris=['dummy:a']).get()
send.reset_mock()
self.core.tracklist.clear().get()
@ -61,7 +58,6 @@ class BackendEventsTest(unittest.TestCase):
def test_tracklist_move_sends_tracklist_changed_event(self, send):
self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get()
send.reset_mock()
self.core.tracklist.move(0, 1, 1).get()
@ -69,7 +65,6 @@ class BackendEventsTest(unittest.TestCase):
def test_tracklist_remove_sends_tracklist_changed_event(self, send):
self.core.tracklist.add(uris=['dummy:a']).get()
send.reset_mock()
self.core.tracklist.remove({'uri': ['dummy:a']}).get()
@ -77,29 +72,22 @@ class BackendEventsTest(unittest.TestCase):
def test_tracklist_shuffle_sends_tracklist_changed_event(self, send):
self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get()
send.reset_mock()
self.core.tracklist.shuffle().get()
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
def test_playlists_refresh_sends_playlists_loaded_event(self, send):
send.reset_mock()
self.core.playlists.refresh().get()
self.assertEqual(send.call_args[0][0], 'playlists_loaded')
def test_playlists_refresh_uri_sends_playlists_loaded_event(self, send):
send.reset_mock()
self.core.playlists.refresh(uri_scheme='dummy').get()
self.assertEqual(send.call_args[0][0], 'playlists_loaded')
def test_playlists_create_sends_playlist_changed_event(self, send):
send.reset_mock()
self.core.playlists.create('foo').get()
self.assertEqual(send.call_args[0][0], 'playlist_changed')
@ -112,7 +100,6 @@ class BackendEventsTest(unittest.TestCase):
def test_playlists_save_sends_playlist_changed_event(self, send):
playlist = self.core.playlists.create('foo').get()
playlist = playlist.replace(name='bar')
send.reset_mock()
self.core.playlists.save(playlist).get()

View File

@ -5,8 +5,8 @@ import unittest
import mock
from mopidy import backend, core
from mopidy.internal import deprecation
from mopidy.models import Image, Ref, SearchResult, Track
from mopidy.utils import deprecation
class BaseCoreLibraryTest(unittest.TestCase):
@ -15,24 +15,25 @@ class BaseCoreLibraryTest(unittest.TestCase):
dummy1_root = Ref.directory(uri='dummy1:directory', name='dummy1')
self.backend1 = mock.Mock()
self.backend1.uri_schemes.get.return_value = ['dummy1']
self.backend1.actor_ref.actor_class.__name__ = 'DummyBackend1'
self.library1 = mock.Mock(spec=backend.LibraryProvider)
self.library1.get_images().get.return_value = {}
self.library1.get_images.reset_mock()
self.library1.get_images.return_value.get.return_value = {}
self.library1.root_directory.get.return_value = dummy1_root
self.backend1.library = self.library1
dummy2_root = Ref.directory(uri='dummy2:directory', name='dummy2')
self.backend2 = mock.Mock()
self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2']
self.backend2.actor_ref.actor_class.__name__ = 'DummyBackend2'
self.library2 = mock.Mock(spec=backend.LibraryProvider)
self.library2.get_images().get.return_value = {}
self.library2.get_images.reset_mock()
self.library2.get_images.return_value.get.return_value = {}
self.library2.root_directory.get.return_value = dummy2_root
self.backend2.library = self.library2
# A backend without the optional library provider
self.backend3 = mock.Mock()
self.backend3.uri_schemes.get.return_value = ['dummy3']
self.backend3.actor_ref.actor_class.__name__ = 'DummyBackend3'
self.backend3.has_library().get.return_value = False
self.backend3.has_library_browse().get.return_value = False
@ -65,20 +66,17 @@ class CoreLibraryTest(BaseCoreLibraryTest):
self.library2.get_images.assert_called_once_with(['dummy2:track'])
def test_get_images_returns_images(self):
self.library1.get_images().get.return_value = {
self.library1.get_images.return_value.get.return_value = {
'dummy1:track': [Image(uri='uri')]}
self.library1.get_images.reset_mock()
result = self.core.library.get_images(['dummy1:track'])
self.assertEqual({'dummy1:track': (Image(uri='uri'),)}, result)
def test_get_images_merges_results(self):
self.library1.get_images().get.return_value = {
self.library1.get_images.return_value.get.return_value = {
'dummy1:track': [Image(uri='uri1')]}
self.library1.get_images.reset_mock()
self.library2.get_images().get.return_value = {
self.library2.get_images.return_value.get.return_value = {
'dummy2:track': [Image(uri='uri2')]}
self.library2.get_images.reset_mock()
result = self.core.library.get_images(
['dummy1:track', 'dummy2:track', 'dummy3:track', 'dummy4:track'])
@ -106,11 +104,10 @@ class CoreLibraryTest(BaseCoreLibraryTest):
self.assertFalse(self.library2.browse.called)
def test_browse_dummy1_selects_dummy1_backend(self):
self.library1.browse().get.return_value = [
self.library1.browse.return_value.get.return_value = [
Ref.directory(uri='dummy1:directory:/foo/bar', name='bar'),
Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'),
]
self.library1.browse.reset_mock()
self.core.library.browse('dummy1:directory:/foo')
@ -119,11 +116,10 @@ class CoreLibraryTest(BaseCoreLibraryTest):
self.library1.browse.assert_called_with('dummy1:directory:/foo')
def test_browse_dummy2_selects_dummy2_backend(self):
self.library2.browse().get.return_value = [
self.library2.browse.return_value.get.return_value = [
Ref.directory(uri='dummy2:directory:/bar/baz', name='quux'),
Ref.track(uri='dummy2:track:/bar/foo.mp3', name='Baz'),
]
self.library2.browse.reset_mock()
self.core.library.browse('dummy2:directory:/bar')
@ -139,11 +135,10 @@ class CoreLibraryTest(BaseCoreLibraryTest):
self.assertEqual(self.library2.browse.call_count, 0)
def test_browse_dir_returns_subdirs_and_tracks(self):
self.library1.browse().get.return_value = [
self.library1.browse.return_value.get.return_value = [
Ref.directory(uri='dummy1:directory:/foo/bar', name='Bar'),
Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'),
]
self.library1.browse.reset_mock()
result = self.core.library.browse('dummy1:directory:/foo')
self.assertEqual(result, [
@ -156,11 +151,14 @@ class CoreLibraryTest(BaseCoreLibraryTest):
self.core.library.lookup('dummy1:a', ['dummy2:a'])
def test_lookup_can_handle_uris(self):
self.library1.lookup().get.return_value = [1234]
self.library2.lookup().get.return_value = [5678]
track1 = Track(name='abc')
track2 = Track(name='def')
self.library1.lookup().get.return_value = [track1]
self.library2.lookup().get.return_value = [track2]
result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a'])
self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]})
self.assertEqual(result, {'dummy2:a': [track2], 'dummy1:a': [track1]})
def test_lookup_uris_returns_empty_list_for_dummy3_track(self):
result = self.core.library.lookup(uris=['dummy3:a'])
@ -199,10 +197,8 @@ class CoreLibraryTest(BaseCoreLibraryTest):
result1 = SearchResult(tracks=[track1])
result2 = SearchResult(tracks=[track2])
self.library1.search().get.return_value = result1
self.library1.search.reset_mock()
self.library2.search().get.return_value = result2
self.library2.search.reset_mock()
self.library1.search.return_value.get.return_value = result1
self.library2.search.return_value.get.return_value = result2
result = self.core.library.search({'any': ['a']})
@ -234,10 +230,8 @@ class CoreLibraryTest(BaseCoreLibraryTest):
track1 = Track(uri='dummy1:a')
result1 = SearchResult(tracks=[track1])
self.library1.search().get.return_value = result1
self.library1.search.reset_mock()
self.library2.search().get.return_value = None
self.library2.search.reset_mock()
self.library1.search.return_value.get.return_value = result1
self.library2.search.return_value.get.return_value = None
result = self.core.library.search({'any': ['a']})
@ -254,10 +248,8 @@ class CoreLibraryTest(BaseCoreLibraryTest):
result1 = SearchResult(tracks=[track1])
result2 = SearchResult(tracks=[track2])
self.library1.search().get.return_value = result1
self.library1.search.reset_mock()
self.library2.search().get.return_value = result2
self.library2.search.reset_mock()
self.library1.search.return_value.get.return_value = result1
self.library2.search.return_value.get.return_value = result2
result = self.core.library.search({'any': ['a']})
@ -363,12 +355,14 @@ class DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest):
return super(DeprecatedLookupCoreLibraryTest, self).run(result)
def test_lookup_selects_dummy1_backend(self):
self.library1.lookup.return_value.get.return_value = []
self.core.library.lookup('dummy1:a')
self.library1.lookup.assert_called_once_with('dummy1:a')
self.assertFalse(self.library2.lookup.called)
def test_lookup_selects_dummy2_backend(self):
self.library2.lookup.return_value.get.return_value = []
self.core.library.lookup('dummy2:a')
self.assertFalse(self.library1.lookup.called)
@ -421,8 +415,7 @@ class LegacyFindExactToSearchLibraryTest(unittest.TestCase):
# We are just testing that this doesn't fail.
@mock.patch('mopidy.core.library.logger')
class BackendFailuresCoreLibraryTest(unittest.TestCase):
class MockBackendCoreLibraryBase(unittest.TestCase):
def setUp(self): # noqa: N802
dummy_root = Ref.directory(uri='dummy:directory', name='dummy')
@ -437,52 +430,182 @@ class BackendFailuresCoreLibraryTest(unittest.TestCase):
self.core = core.Core(mixer=None, backends=[self.backend])
def test_browse_backend_get_root_exception_gets_ignored(self, logger):
@mock.patch('mopidy.core.library.logger')
class BrowseBadBackendTest(MockBackendCoreLibraryBase):
def test_backend_raises_exception_for_root(self, logger):
# Might happen if root_directory is a property for some weird reason.
self.library.root_directory.get.side_effect = Exception
self.assertEqual([], self.core.library.browse(None))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_browse_backend_browse_uri_exception_gets_ignored(self, logger):
def test_backend_returns_none_for_root(self, logger):
self.library.root_directory.get.return_value = None
self.assertEqual([], self.core.library.browse(None))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_returns_wrong_type_for_root(self, logger):
self.library.root_directory.get.return_value = 123
self.assertEqual([], self.core.library.browse(None))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_raises_exception_for_browse(self, logger):
self.library.browse.return_value.get.side_effect = Exception
self.assertEqual([], self.core.library.browse('dummy:directory'))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_get_distinct_backend_exception_gets_ignored(self, logger):
def test_backend_returns_wrong_type_for_browse(self, logger):
self.library.browse.return_value.get.return_value = [123]
self.assertEqual([], self.core.library.browse('dummy:directory'))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
@mock.patch('mopidy.core.library.logger')
class GetDistinctBadBackendTest(MockBackendCoreLibraryBase):
def test_backend_raises_exception(self, logger):
self.library.get_distinct.return_value.get.side_effect = Exception
self.assertEqual(set(), self.core.library.get_distinct('artist'))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_get_images_backend_exception_get_ignored(self, logger):
def test_backend_returns_none(self, logger):
self.library.get_distinct.return_value.get.return_value = None
self.assertEqual(set(), self.core.library.get_distinct('artist'))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
self.library.get_distinct.return_value.get.return_value = 'abc'
self.assertEqual(set(), self.core.library.get_distinct('artist'))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_returns_iterable_containing_wrong_types(self, logger):
self.library.get_distinct.return_value.get.return_value = [1, 2, 3]
self.assertEqual(set(), self.core.library.get_distinct('artist'))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
@mock.patch('mopidy.core.library.logger')
class GetImagesBadBackendTest(MockBackendCoreLibraryBase):
def test_backend_raises_exception(self, logger):
uri = 'dummy:/1'
self.library.get_images.return_value.get.side_effect = Exception
self.assertEqual(
{'dummy:/1': tuple()}, self.core.library.get_images(['dummy:/1']))
self.assertEqual({uri: tuple()}, self.core.library.get_images([uri]))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_lookup_backend_exceptiosn_gets_ignores(self, logger):
def test_backend_returns_none(self, logger):
uri = 'dummy:/1'
self.library.get_images.return_value.get.return_value = None
self.assertEqual({uri: tuple()}, self.core.library.get_images([uri]))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
uri = 'dummy:/1'
self.library.get_images.return_value.get.return_value = 'abc'
self.assertEqual({uri: tuple()}, self.core.library.get_images([uri]))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_returns_mapping_containing_wrong_types(self, logger):
uri = 'dummy:/1'
self.library.get_images.return_value.get.return_value = {uri: 'abc'}
self.assertEqual({uri: tuple()}, self.core.library.get_images([uri]))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_returns_mapping_containing_none(self, logger):
uri = 'dummy:/1'
self.library.get_images.return_value.get.return_value = {uri: None}
self.assertEqual({uri: tuple()}, self.core.library.get_images([uri]))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_returns_unknown_uri(self, logger):
uri = 'dummy:/1'
self.library.get_images.return_value.get.return_value = {'foo': []}
self.assertEqual({uri: tuple()}, self.core.library.get_images([uri]))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
@mock.patch('mopidy.core.library.logger')
class LookupByUrisBadBackendTest(MockBackendCoreLibraryBase):
def test_backend_raises_exception(self, logger):
uri = 'dummy:/1'
self.library.lookup.return_value.get.side_effect = Exception
self.assertEqual(
{'dummy:/1': []}, self.core.library.lookup(uris=['dummy:/1']))
self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri]))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_refresh_backend_exception_gets_ignored(self, logger):
def test_backend_returns_none(self, logger):
uri = 'dummy:/1'
self.library.lookup.return_value.get.return_value = None
self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri]))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
uri = 'dummy:/1'
self.library.lookup.return_value.get.return_value = 'abc'
self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri]))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_returns_iterable_containing_wrong_types(self, logger):
uri = 'dummy:/1'
self.library.lookup.return_value.get.return_value = [123]
self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri]))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_returns_none_with_uri(self, logger):
uri = 'dummy:/1'
self.library.lookup.return_value.get.return_value = None
self.assertEqual([], self.core.library.lookup(uri))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type_with_uri(self, logger):
uri = 'dummy:/1'
self.library.lookup.return_value.get.return_value = 'abc'
self.assertEqual([], self.core.library.lookup(uri))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
def test_backend_returns_iterable_wrong_types_with_uri(self, logger):
uri = 'dummy:/1'
self.library.lookup.return_value.get.return_value = [123]
self.assertEqual([], self.core.library.lookup(uri))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
@mock.patch('mopidy.core.library.logger')
class RefreshBadBackendTest(MockBackendCoreLibraryBase):
def test_backend_raises_exception(self, logger):
self.library.refresh.return_value.get.side_effect = Exception
self.core.library.refresh()
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_refresh_uri_backend_exception_gets_ignored(self, logger):
def test_backend_raises_exception_with_uri(self, logger):
self.library.refresh.return_value.get.side_effect = Exception
self.core.library.refresh('dummy:/1')
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_search_backend_exception_gets_ignored(self, logger):
@mock.patch('mopidy.core.library.logger')
class SearchBadBackendTest(MockBackendCoreLibraryBase):
def test_backend_raises_exception(self, logger):
self.library.search.return_value.get.side_effect = Exception
self.assertEqual([], self.core.library.search(query={'any': ['foo']}))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_search_backend_lookup_error_gets_through(self, logger):
def test_backend_raises_lookuperror(self, logger):
# TODO: is this behavior desired? Do we need to continue handling
# LookupError case specially.
self.library.search.return_value.get.side_effect = LookupError
with self.assertRaises(LookupError):
self.core.library.search(query={'any': ['foo']})
def test_backend_returns_none(self, logger):
self.library.search.return_value.get.return_value = None
self.assertEqual([], self.core.library.search(query={'any': ['foo']}))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
self.library.search.return_value.get.return_value = 'abc'
self.assertEqual([], self.core.library.search(query={'any': ['foo']}))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)

View File

@ -23,6 +23,7 @@ class CoreMixerTest(unittest.TestCase):
self.mixer.get_volume.assert_called_once_with()
def test_set_volume(self):
self.mixer.set_volume.return_value.get.return_value = True
self.core.mixer.set_volume(30)
self.mixer.set_volume.assert_called_once_with(30)
@ -34,6 +35,7 @@ class CoreMixerTest(unittest.TestCase):
self.mixer.get_mute.assert_called_once_with()
def test_set_mute(self):
self.mixer.set_mute.return_value.get.return_value = True
self.core.mixer.set_mute(True)
self.mixer.set_mute.assert_called_once_with(True)
@ -92,3 +94,63 @@ class CoreNoneMixerListenerTest(unittest.TestCase):
def test_forwards_mixer_mute_changed_event_to_frontends(self, send):
self.core.mixer.set_mute(mute=True)
self.assertEqual(send.call_count, 0)
class MockBackendCoreMixerBase(unittest.TestCase):
def setUp(self): # noqa: N802
self.mixer = mock.Mock()
self.mixer.actor_ref.actor_class.__name__ = 'DummyMixer'
self.core = core.Core(mixer=self.mixer, backends=[])
class GetVolumeBadBackendTest(MockBackendCoreMixerBase):
def test_backend_raises_exception(self):
self.mixer.get_volume.return_value.get.side_effect = Exception
self.assertEqual(self.core.mixer.get_volume(), None)
def test_backend_returns_too_small_value(self):
self.mixer.get_volume.return_value.get.return_value = -1
self.assertEqual(self.core.mixer.get_volume(), None)
def test_backend_returns_too_large_value(self):
self.mixer.get_volume.return_value.get.return_value = 1000
self.assertEqual(self.core.mixer.get_volume(), None)
def test_backend_returns_wrong_type(self):
self.mixer.get_volume.return_value.get.return_value = '12'
self.assertEqual(self.core.mixer.get_volume(), None)
class SetVolumeBadBackendTest(MockBackendCoreMixerBase):
def test_backend_raises_exception(self):
self.mixer.set_volume.return_value.get.side_effect = Exception
self.assertFalse(self.core.mixer.set_volume(30))
def test_backend_returns_wrong_type(self):
self.mixer.set_volume.return_value.get.return_value = 'done'
self.assertFalse(self.core.mixer.set_volume(30))
class GetMuteBadBackendTest(MockBackendCoreMixerBase):
def test_backend_raises_exception(self):
self.mixer.get_mute.return_value.get.side_effect = Exception
self.assertEqual(self.core.mixer.get_mute(), None)
def test_backend_returns_wrong_type(self):
self.mixer.get_mute.return_value.get.return_value = '12'
self.assertEqual(self.core.mixer.get_mute(), None)
class SetMuteBadBackendTest(MockBackendCoreMixerBase):
def test_backend_raises_exception(self):
self.mixer.set_mute.return_value.get.side_effect = Exception
self.assertFalse(self.core.mixer.set_mute(True))
def test_backend_returns_wrong_type(self):
self.mixer.set_mute.return_value.get.return_value = 'done'
self.assertFalse(self.core.mixer.set_mute(True))

View File

@ -7,8 +7,8 @@ import mock
import pykka
from mopidy import backend, core
from mopidy.internal import deprecation
from mopidy.models import Track
from mopidy.utils import deprecation
from tests import dummy_audio as audio
@ -21,15 +21,13 @@ class CorePlaybackTest(unittest.TestCase):
self.backend1 = mock.Mock()
self.backend1.uri_schemes.get.return_value = ['dummy1']
self.playback1 = mock.Mock(spec=backend.PlaybackProvider)
self.playback1.get_time_position().get.return_value = 1000
self.playback1.reset_mock()
self.playback1.get_time_position.return_value.get.return_value = 1000
self.backend1.playback = self.playback1
self.backend2 = mock.Mock()
self.backend2.uri_schemes.get.return_value = ['dummy2']
self.playback2 = mock.Mock(spec=backend.PlaybackProvider)
self.playback2.get_time_position().get.return_value = 2000
self.playback2.reset_mock()
self.playback2.get_time_position.return_value.get.return_value = 2000
self.backend2.playback = self.playback2
# A backend without the optional playback provider
@ -123,6 +121,17 @@ class CorePlaybackTest(unittest.TestCase):
self.assertEqual(
self.core.playback.get_current_track(), self.tracks[0])
def test_get_current_tlid_none(self):
self.set_current_tl_track(None)
self.assertEqual(self.core.playback.get_current_tlid(), None)
def test_get_current_tlid_play(self):
self.core.playback.play(self.tl_tracks[0])
self.assertEqual(
self.core.playback.get_current_tlid(), self.tl_tracks[0].tlid)
# TODO Test state
def test_play_selects_dummy1_backend(self):

View File

@ -5,8 +5,8 @@ import unittest
import mock
from mopidy import backend, core
from mopidy.internal import deprecation
from mopidy.models import Playlist, Ref, Track
from mopidy.utils import deprecation
class BasePlaylistsTest(unittest.TestCase):
@ -90,8 +90,7 @@ class PlaylistTest(BasePlaylistsTest):
def test_create_without_uri_scheme_uses_first_backend(self):
playlist = Playlist()
self.sp1.create().get.return_value = playlist
self.sp1.reset_mock()
self.sp1.create.return_value.get.return_value = playlist
result = self.core.playlists.create('foo')
@ -99,10 +98,31 @@ class PlaylistTest(BasePlaylistsTest):
self.sp1.create.assert_called_once_with('foo')
self.assertFalse(self.sp2.create.called)
def test_create_without_uri_scheme_ignores_none_result(self):
playlist = Playlist()
self.sp1.create.return_value.get.return_value = None
self.sp2.create.return_value.get.return_value = playlist
result = self.core.playlists.create('foo')
self.assertEqual(playlist, result)
self.sp1.create.assert_called_once_with('foo')
self.sp2.create.assert_called_once_with('foo')
def test_create_without_uri_scheme_ignores_exception(self):
playlist = Playlist()
self.sp1.create.return_value.get.side_effect = Exception
self.sp2.create.return_value.get.return_value = playlist
result = self.core.playlists.create('foo')
self.assertEqual(playlist, result)
self.sp1.create.assert_called_once_with('foo')
self.sp2.create.assert_called_once_with('foo')
def test_create_with_uri_scheme_selects_the_matching_backend(self):
playlist = Playlist()
self.sp2.create().get.return_value = playlist
self.sp2.reset_mock()
self.sp2.create.return_value.get.return_value = playlist
result = self.core.playlists.create('foo', uri_scheme='dummy2')
@ -112,8 +132,7 @@ class PlaylistTest(BasePlaylistsTest):
def test_create_with_unsupported_uri_scheme_uses_first_backend(self):
playlist = Playlist()
self.sp1.create().get.return_value = playlist
self.sp1.reset_mock()
self.sp1.create.return_value.get.return_value = playlist
result = self.core.playlists.create('foo', uri_scheme='dummy3')
@ -190,8 +209,7 @@ class PlaylistTest(BasePlaylistsTest):
def test_save_selects_the_dummy1_backend(self):
playlist = Playlist(uri='dummy1:a')
self.sp1.save().get.return_value = playlist
self.sp1.reset_mock()
self.sp1.save.return_value.get.return_value = playlist
result = self.core.playlists.save(playlist)
@ -201,8 +219,7 @@ class PlaylistTest(BasePlaylistsTest):
def test_save_selects_the_dummy2_backend(self):
playlist = Playlist(uri='dummy2:a')
self.sp2.save().get.return_value = playlist
self.sp2.reset_mock()
self.sp2.save.return_value.get.return_value = playlist
result = self.core.playlists.save(playlist)
@ -281,8 +298,7 @@ class DeprecatedGetPlaylistsTest(BasePlaylistsTest):
self.assertEqual(len(result[1].tracks), 0)
@mock.patch('mopidy.core.playlists.logger')
class BackendFailuresCorePlaylistsTest(unittest.TestCase):
class MockBackendCorePlaylistsBase(unittest.TestCase):
def setUp(self): # noqa: N802
self.playlists = mock.Mock(spec=backend.PlaylistsProvider)
@ -294,27 +310,127 @@ class BackendFailuresCorePlaylistsTest(unittest.TestCase):
self.core = core.Core(mixer=None, backends=[self.backend])
def test_as_list_backend_exception_gets_ignored(self, logger):
self.playlists.as_list.get.side_effect = Exception
@mock.patch('mopidy.core.playlists.logger')
class AsListBadBackendsTest(MockBackendCorePlaylistsBase):
def test_backend_raises_exception(self, logger):
self.playlists.as_list.return_value.get.side_effect = Exception
self.assertEqual([], self.core.playlists.as_list())
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_get_items_backend_exception_gets_through(self, logger):
# TODO: is this behavior desired?
def test_backend_returns_none(self, logger):
self.playlists.as_list.return_value.get.return_value = None
self.assertEqual([], self.core.playlists.as_list())
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
self.playlists.as_list.return_value.get.return_value = 'abc'
self.assertEqual([], self.core.playlists.as_list())
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
@mock.patch('mopidy.core.playlists.logger')
class GetItemsBadBackendsTest(MockBackendCorePlaylistsBase):
def test_backend_raises_exception(self, logger):
self.playlists.get_items.return_value.get.side_effect = Exception
with self.assertRaises(Exception):
self.core.playlists.get_items('dummy:/1')
self.assertIsNone(self.core.playlists.get_items('dummy:/1'))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_backend_returns_none(self, logger):
self.playlists.get_items.return_value.get.return_value = None
self.assertIsNone(self.core.playlists.get_items('dummy:/1'))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
self.playlists.get_items.return_value.get.return_value = 'abc'
self.assertIsNone(self.core.playlists.get_items('dummy:/1'))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
@mock.patch('mopidy.core.playlists.logger')
class CreateBadBackendsTest(MockBackendCorePlaylistsBase):
def test_backend_raises_exception(self, logger):
self.playlists.create.return_value.get.side_effect = Exception
self.assertIsNone(self.core.playlists.create('foobar'))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_backend_returns_none(self, logger):
self.playlists.create.return_value.get.return_value = None
self.assertIsNone(self.core.playlists.create('foobar'))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
self.playlists.create.return_value.get.return_value = 'abc'
self.assertIsNone(self.core.playlists.create('foobar'))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
@mock.patch('mopidy.core.playlists.logger')
class DeleteBadBackendsTest(MockBackendCorePlaylistsBase):
def test_backend_raises_exception(self, logger):
self.playlists.delete.return_value.get.side_effect = Exception
self.assertIsNone(self.core.playlists.delete('dummy:/1'))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
@mock.patch('mopidy.core.playlists.logger')
class LookupBadBackendsTest(MockBackendCorePlaylistsBase):
def test_backend_raises_exception(self, logger):
self.playlists.lookup.return_value.get.side_effect = Exception
self.assertIsNone(self.core.playlists.lookup('dummy:/1'))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_backend_returns_none(self, logger):
self.playlists.lookup.return_value.get.return_value = None
self.assertIsNone(self.core.playlists.lookup('dummy:/1'))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
self.playlists.lookup.return_value.get.return_value = 'abc'
self.assertIsNone(self.core.playlists.lookup('dummy:/1'))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)
@mock.patch('mopidy.core.playlists.logger')
class RefreshBadBackendsTest(MockBackendCorePlaylistsBase):
@mock.patch('mopidy.core.listener.CoreListener.send')
def test_refresh_backend_exception_gets_ignored(self, send, logger):
def test_backend_raises_exception(self, send, logger):
self.playlists.refresh.return_value.get.side_effect = Exception
self.core.playlists.refresh()
self.assertFalse(send.called)
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
@mock.patch('mopidy.core.listener.CoreListener.send')
def test_refresh_uri_backend_exception_gets_ignored(self, send, logger):
def test_backend_raises_exception_called_with_uri(self, send, logger):
self.playlists.refresh.return_value.get.side_effect = Exception
self.core.playlists.refresh('dummy')
self.assertFalse(send.called)
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
@mock.patch('mopidy.core.playlists.logger')
class SaveBadBackendsTest(MockBackendCorePlaylistsBase):
def test_backend_raises_exception(self, logger):
playlist = Playlist(uri='dummy:/1')
self.playlists.save.return_value.get.side_effect = Exception
self.assertIsNone(self.core.playlists.save(playlist))
logger.exception.assert_called_with(mock.ANY, 'DummyBackend')
def test_backend_returns_none(self, logger):
playlist = Playlist(uri='dummy:/1')
self.playlists.save.return_value.get.return_value = None
self.assertIsNone(self.core.playlists.save(playlist))
self.assertFalse(logger.error.called)
def test_backend_returns_wrong_type(self, logger):
playlist = Playlist(uri='dummy:/1')
self.playlists.save.return_value.get.return_value = 'abc'
self.assertIsNone(self.core.playlists.save(playlist))
logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY)

View File

@ -5,8 +5,8 @@ import unittest
import mock
from mopidy import backend, core
from mopidy.internal import deprecation
from mopidy.models import TlTrack, Track
from mopidy.utils import deprecation
class TracklistTest(unittest.TestCase):

View File

@ -12,8 +12,6 @@ from mopidy.http import actor
class HttpEventsTest(unittest.TestCase):
def test_track_playback_paused_is_broadcasted(self, broadcast):
broadcast.reset_mock()
actor.on_event('track_playback_paused', foo='bar')
self.assertDictEqual(
@ -23,8 +21,6 @@ class HttpEventsTest(unittest.TestCase):
})
def test_track_playback_resumed_is_broadcasted(self, broadcast):
broadcast.reset_mock()
actor.on_event('track_playback_resumed', foo='bar')
self.assertDictEqual(

View File

@ -11,7 +11,7 @@ from mock import Mock, call, patch, sentinel
import pykka
from mopidy.utils import network
from mopidy.internal import network
from tests import any_int, any_unicode

View File

@ -8,7 +8,7 @@ import unittest
from mock import Mock, sentinel
from mopidy import compat
from mopidy.utils import network
from mopidy.internal import network
from tests import any_unicode

View File

@ -8,7 +8,7 @@ import gobject
from mock import Mock, patch, sentinel
from mopidy.utils import network
from mopidy.internal import network
from tests import any_int

View File

@ -5,18 +5,18 @@ import unittest
from mock import Mock, patch
from mopidy.utils import network
from mopidy.internal import network
class FormatHostnameTest(unittest.TestCase):
@patch('mopidy.utils.network.has_ipv6', True)
@patch('mopidy.internal.network.has_ipv6', True)
def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self):
network.has_ipv6 = True
self.assertEqual(network.format_hostname('0.0.0.0'), '::ffff:0.0.0.0')
self.assertEqual(network.format_hostname('1.0.0.1'), '::ffff:1.0.0.1')
@patch('mopidy.utils.network.has_ipv6', False)
@patch('mopidy.internal.network.has_ipv6', False)
def test_format_hostname_does_nothing_when_only_ipv4_available(self):
network.has_ipv6 = False
self.assertEqual(network.format_hostname('0.0.0.0'), '0.0.0.0')
@ -43,14 +43,14 @@ class TryIPv6SocketTest(unittest.TestCase):
class CreateSocketTest(unittest.TestCase):
@patch('mopidy.utils.network.has_ipv6', False)
@patch('mopidy.internal.network.has_ipv6', False)
@patch('socket.socket')
def test_ipv4_socket(self, socket_mock):
network.create_socket()
self.assertEqual(
socket_mock.call_args[0], (socket.AF_INET, socket.SOCK_STREAM))
@patch('mopidy.utils.network.has_ipv6', True)
@patch('mopidy.internal.network.has_ipv6', True)
@patch('socket.socket')
def test_ipv6_socket(self, socket_mock):
network.create_socket()

View File

@ -12,7 +12,7 @@ import pygst
pygst.require('0.10')
import gst # noqa
from mopidy.utils import deps
from mopidy.internal import deps
class DepsTest(unittest.TestCase):

View File

@ -4,16 +4,16 @@ import unittest
import mock
from mopidy.utils.encoding import locale_decode
from mopidy.internal import encoding
@mock.patch('mopidy.utils.encoding.locale.getpreferredencoding')
@mock.patch('mopidy.internal.encoding.locale.getpreferredencoding')
class LocaleDecodeTest(unittest.TestCase):
def test_can_decode_utf8_strings_with_french_content(self, mock):
mock.return_value = 'UTF-8'
result = locale_decode(
result = encoding.locale_decode(
b'[Errno 98] Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e')
self.assertEqual('[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result)
@ -22,7 +22,7 @@ class LocaleDecodeTest(unittest.TestCase):
mock.return_value = 'UTF-8'
error = IOError(98, b'Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e')
result = locale_decode(error)
result = encoding.locale_decode(error)
expected = '[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e'
self.assertEqual(
@ -33,13 +33,13 @@ class LocaleDecodeTest(unittest.TestCase):
def test_does_not_use_locale_to_decode_unicode_strings(self, mock):
mock.return_value = 'UTF-8'
locale_decode('abc')
encoding.locale_decode('abc')
self.assertFalse(mock.called)
def test_does_not_use_locale_to_decode_ascii_bytestrings(self, mock):
mock.return_value = 'UTF-8'
locale_decode('abc')
encoding.locale_decode('abc')
self.assertFalse(mock.called)

View File

@ -8,7 +8,7 @@ import mock
import pykka
from mopidy import core, models
from mopidy.utils import deprecation, jsonrpc
from mopidy.internal import deprecation, jsonrpc
from tests import dummy_backend

View File

@ -10,7 +10,7 @@ import unittest
import glib
from mopidy import compat, exceptions
from mopidy.utils import path
from mopidy.internal import path
import tests

View File

@ -3,7 +3,7 @@ from __future__ import absolute_import, unicode_literals
from pytest import raises
from mopidy import compat, exceptions
from mopidy.utils import validation
from mopidy.internal import validation
def test_check_boolean_with_valid_values():

View File

@ -6,7 +6,7 @@ import mock
import pytest
from mopidy.utils import xdg
from mopidy.internal import xdg
@pytest.yield_fixture

View File

@ -1,6 +1,6 @@
from __future__ import absolute_import, unicode_literals
from mopidy.utils import deprecation
from mopidy.internal import deprecation
def generate_song(i):

View File

@ -9,9 +9,9 @@ import pykka
from mopidy import core
from mopidy.core import PlaybackState
from mopidy.internal import deprecation
from mopidy.local import actor
from mopidy.models import TlTrack, Track
from mopidy.utils import deprecation
from tests import dummy_audio, path_to_data_dir
from tests.local import generate_song, populate_tracklist

View File

@ -7,9 +7,9 @@ import pykka
from mopidy import core
from mopidy.core import PlaybackState
from mopidy.internal import deprecation
from mopidy.local import actor
from mopidy.models import Playlist, Track
from mopidy.utils import deprecation
from tests import dummy_audio, path_to_data_dir
from tests.local import generate_song, populate_tracklist

View File

@ -0,0 +1,98 @@
# encoding: utf-8
from __future__ import unicode_literals
import pytest
from mopidy.local import translator
@pytest.mark.parametrize('local_uri,file_uri', [
('local:directory:A/B', 'file:///home/alice/Music/A/B'),
('local:directory:A%20B', 'file:///home/alice/Music/A%20B'),
('local:directory:A+B', 'file:///home/alice/Music/A%2BB'),
(
'local:directory:%C3%A6%C3%B8%C3%A5',
'file:///home/alice/Music/%C3%A6%C3%B8%C3%A5'),
('local:track:A/B.mp3', 'file:///home/alice/Music/A/B.mp3'),
('local:track:A%20B.mp3', 'file:///home/alice/Music/A%20B.mp3'),
('local:track:A+B.mp3', 'file:///home/alice/Music/A%2BB.mp3'),
(
'local:track:%C3%A6%C3%B8%C3%A5.mp3',
'file:///home/alice/Music/%C3%A6%C3%B8%C3%A5.mp3'),
])
def test_local_uri_to_file_uri(local_uri, file_uri):
media_dir = b'/home/alice/Music'
assert translator.local_uri_to_file_uri(local_uri, media_dir) == file_uri
@pytest.mark.parametrize('uri', [
'A/B',
'local:foo:A/B',
])
def test_local_uri_to_file_uri_errors(uri):
media_dir = b'/home/alice/Music'
with pytest.raises(ValueError):
translator.local_uri_to_file_uri(uri, media_dir)
@pytest.mark.parametrize('uri,path', [
('local:directory:A/B', b'/home/alice/Music/A/B'),
('local:directory:A%20B', b'/home/alice/Music/A B'),
('local:directory:A+B', b'/home/alice/Music/A+B'),
('local:directory:%C3%A6%C3%B8%C3%A5', b'/home/alice/Music/æøå'),
('local:track:A/B.mp3', b'/home/alice/Music/A/B.mp3'),
('local:track:A%20B.mp3', b'/home/alice/Music/A B.mp3'),
('local:track:A+B.mp3', b'/home/alice/Music/A+B.mp3'),
('local:track:%C3%A6%C3%B8%C3%A5.mp3', b'/home/alice/Music/æøå.mp3'),
])
def test_local_uri_to_path(uri, path):
media_dir = b'/home/alice/Music'
assert translator.local_uri_to_path(uri, media_dir) == path
# Legacy version to keep old versions of Mopidy-Local-Sqlite working
assert translator.local_track_uri_to_path(uri, media_dir) == path
@pytest.mark.parametrize('uri', [
'A/B',
'local:foo:A/B',
])
def test_local_uri_to_path_errors(uri):
media_dir = b'/home/alice/Music'
with pytest.raises(ValueError):
translator.local_uri_to_path(uri, media_dir)
@pytest.mark.parametrize('path,uri', [
('/foo', 'file:///foo'),
(b'/foo', 'file:///foo'),
('/æøå', 'file:///%C3%A6%C3%B8%C3%A5'),
(b'/\x00\x01\x02', 'file:///%00%01%02'),
])
def test_path_to_file_uri(path, uri):
assert translator.path_to_file_uri(path) == uri
@pytest.mark.parametrize('path,uri', [
('foo', 'local:track:foo'),
(b'foo', 'local:track:foo'),
('æøå', 'local:track:%C3%A6%C3%B8%C3%A5'),
(b'\x00\x01\x02', 'local:track:%00%01%02'),
])
def test_path_to_local_track_uri(path, uri):
assert translator.path_to_local_track_uri(path) == uri
@pytest.mark.parametrize('path,uri', [
('foo', 'local:directory:foo'),
(b'foo', 'local:directory:foo'),
('æøå', 'local:directory:%C3%A6%C3%B8%C3%A5'),
(b'\x00\x01\x02', 'local:directory:%00%01%02'),
])
def test_path_to_local_directory_uri(path, uri):
assert translator.path_to_local_directory_uri(path) == uri

View File

@ -8,10 +8,10 @@ import unittest
import pykka
from mopidy import core
from mopidy.internal import deprecation
from mopidy.m3u import actor
from mopidy.m3u.translator import playlist_uri_to_path
from mopidy.models import Playlist, Track
from mopidy.utils import deprecation
from tests import dummy_audio, path_to_data_dir
from tests.m3u import generate_song

View File

@ -6,9 +6,9 @@ import os
import tempfile
import unittest
from mopidy.internal import path
from mopidy.m3u import translator
from mopidy.models import Track
from mopidy.utils import path
from tests import path_to_data_dir

View File

@ -3,15 +3,14 @@ from __future__ import absolute_import, unicode_literals
import unittest
from mopidy.models.fields import * # noqa: F403
from mopidy.models.immutable import ImmutableObjectMeta
def create_instance(field):
"""Create an instance of a dummy class for testing fields."""
class Dummy(object):
__metaclass__ = ImmutableObjectMeta
attr = field
attr._name = 'attr'
return Dummy()

164
tests/models/test_legacy.py Normal file
View File

@ -0,0 +1,164 @@
from __future__ import absolute_import, unicode_literals
import unittest
from mopidy.models import ImmutableObject
class Model(ImmutableObject):
uri = None
name = None
models = frozenset()
def __init__(self, *args, **kwargs):
self.__dict__['models'] = frozenset(kwargs.pop('models', None) or [])
super(Model, self).__init__(self, *args, **kwargs)
class SubModel(ImmutableObject):
uri = None
name = None
class GenericCopyTest(unittest.TestCase):
def compare(self, orig, other):
self.assertEqual(orig, other)
self.assertNotEqual(id(orig), id(other))
def test_copying_model(self):
model = Model()
self.compare(model, model.replace())
def test_copying_model_with_basic_values(self):
model = Model(name='foo', uri='bar')
other = model.replace(name='baz')
self.assertEqual('baz', other.name)
self.assertEqual('bar', other.uri)
def test_copying_model_with_missing_values(self):
model = Model(uri='bar')
other = model.replace(name='baz')
self.assertEqual('baz', other.name)
self.assertEqual('bar', other.uri)
def test_copying_model_with_private_internal_value(self):
model = Model(models=[SubModel(name=123)])
other = model.replace(models=[SubModel(name=345)])
self.assertIn(SubModel(name=345), other.models)
def test_copying_model_with_invalid_key(self):
with self.assertRaises(TypeError):
Model().replace(invalid_key=True)
def test_copying_model_to_remove(self):
model = Model(name='foo').replace(name=None)
self.assertEqual(model, Model())
class ModelTest(unittest.TestCase):
def test_uri(self):
uri = 'an_uri'
model = Model(uri=uri)
self.assertEqual(model.uri, uri)
with self.assertRaises(AttributeError):
model.uri = None
def test_name(self):
name = 'a name'
model = Model(name=name)
self.assertEqual(model.name, name)
with self.assertRaises(AttributeError):
model.name = None
def test_submodels(self):
models = [SubModel(name=123), SubModel(name=456)]
model = Model(models=models)
self.assertEqual(set(model.models), set(models))
with self.assertRaises(AttributeError):
model.models = None
def test_models_none(self):
self.assertEqual(set(), Model(models=None).models)
def test_invalid_kwarg(self):
with self.assertRaises(TypeError):
Model(foo='baz')
def test_repr_without_models(self):
self.assertEqual(
"Model(name=u'name', uri=u'uri')",
repr(Model(uri='uri', name='name')))
def test_repr_with_models(self):
self.assertEqual(
"Model(models=[SubModel(name=123)], name=u'name', uri=u'uri')",
repr(Model(uri='uri', name='name', models=[SubModel(name=123)])))
def test_serialize_without_models(self):
self.assertDictEqual(
{'__model__': 'Model', 'uri': 'uri', 'name': 'name'},
Model(uri='uri', name='name').serialize())
def test_serialize_with_models(self):
submodel = SubModel(name=123)
self.assertDictEqual(
{'__model__': 'Model', 'uri': 'uri', 'name': 'name',
'models': [submodel.serialize()]},
Model(uri='uri', name='name', models=[submodel]).serialize())
def test_eq_uri(self):
model1 = Model(uri='uri1')
model2 = Model(uri='uri1')
self.assertEqual(model1, model2)
self.assertEqual(hash(model1), hash(model2))
def test_eq_name(self):
model1 = Model(name='name1')
model2 = Model(name='name1')
self.assertEqual(model1, model2)
self.assertEqual(hash(model1), hash(model2))
def test_eq_models(self):
models = [SubModel()]
model1 = Model(models=models)
model2 = Model(models=models)
self.assertEqual(model1, model2)
self.assertEqual(hash(model1), hash(model2))
def test_eq_models_order(self):
submodel1 = SubModel(name='name1')
submodel2 = SubModel(name='name2')
model1 = Model(models=[submodel1, submodel2])
model2 = Model(models=[submodel2, submodel1])
self.assertEqual(model1, model2)
self.assertEqual(hash(model1), hash(model2))
def test_eq_none(self):
self.assertNotEqual(Model(), None)
def test_eq_other(self):
self.assertNotEqual(Model(), 'other')
def test_ne_uri(self):
model1 = Model(uri='uri1')
model2 = Model(uri='uri2')
self.assertNotEqual(model1, model2)
self.assertNotEqual(hash(model1), hash(model2))
def test_ne_name(self):
model1 = Model(name='name1')
model2 = Model(name='name2')
self.assertNotEqual(model1, model2)
self.assertNotEqual(hash(model1), hash(model2))
def test_ne_models(self):
model1 = Model(models=[SubModel(name='name1')])
model2 = Model(models=[SubModel(name='name2')])
self.assertNotEqual(model1, model2)
self.assertNotEqual(hash(model1), hash(model2))
def test_ignores_values_with_default_value_none(self):
model1 = Model(name='name1')
model2 = Model(name='name1', uri=None)
self.assertEqual(model1, model2)
self.assertEqual(hash(model1), hash(model2))

View File

@ -18,6 +18,26 @@ class InheritanceTest(unittest.TestCase):
class Foo(Track):
pass
def test_sub_class_can_have_its_own_slots(self):
# Needed for things like SpotifyTrack in mopidy-spotify 1.x
class Foo(Track):
__slots__ = ('_foo',)
f = Foo()
f._foo = 123
def test_sub_class_can_be_initialized(self):
# Fails with following error if fields are not handled across classes.
# TypeError: __init__() got an unexpected keyword argument "type"
# Essentially this is testing that sub-classes take parent _fields into
# account.
class Foo(Ref):
pass
Foo.directory()
class CachingTest(unittest.TestCase):
@ -1148,9 +1168,3 @@ class SearchResultTest(unittest.TestCase):
self.assertDictEqual(
{'__model__': 'SearchResult', 'uri': 'uri'},
SearchResult(uri='uri').serialize())
def test_to_json_and_back(self):
result1 = SearchResult(uri='uri')
serialized = json.dumps(result1, cls=ModelJSONEncoder)
result2 = json.loads(serialized, object_hook=model_json_decoder)
self.assertEqual(result1, result2)

View File

@ -7,8 +7,8 @@ import mock
import pykka
from mopidy import core
from mopidy.internal import deprecation
from mopidy.mpd import session, uri_mapper
from mopidy.utils import deprecation
from tests import dummy_backend, dummy_mixer

View File

@ -1,7 +1,7 @@
from __future__ import absolute_import, unicode_literals
from mopidy.internal import deprecation
from mopidy.models import Ref, Track
from mopidy.utils import deprecation
from tests.mpd import protocol

Some files were not shown because too many files have changed in this diff Show More