From 8f96bf0f394f2a19e85400dda622c78e6188117e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 11:33:32 +0200 Subject: [PATCH 001/318] tests: Fix some model use oddities --- tests/local/test_tracklist.py | 2 +- tests/mpd/test_translator.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 22d4c954..ca36ac44 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -103,7 +103,7 @@ class LocalTracklistProviderTest(unittest.TestCase): def test_filter_by_uri_returns_nothing_if_no_match(self): self.controller.playlist = Playlist( - tracks=[Track(uri=['z']), Track(uri=['y'])]) + tracks=[Track(uri='z'), Track(uri='y')]) self.assertEqual([], self.controller.filter(uri=['a'])) def test_filter_by_multiple_criteria_returns_elements_matching_all(self): diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index bf50687d..3a9b00d8 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, unicode_literals -import datetime import unittest from mopidy.models import Album, Artist, Playlist, TlTrack, Track @@ -20,8 +19,8 @@ class TrackMpdFormatTest(unittest.TestCase): composers=[Artist(name='a composer')], performers=[Artist(name='a performer')], genre='a genre', - date=datetime.date(1977, 1, 1), - disc_no='1', + date='1977-1-1', + disc_no=1, comment='a comment', length=137000, ) @@ -73,8 +72,8 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertIn(('Performer', 'a performer'), result) self.assertIn(('Genre', 'a genre'), result) self.assertIn(('Track', '7/13'), result) - self.assertIn(('Date', datetime.date(1977, 1, 1)), result) - self.assertIn(('Disc', '1'), result) + self.assertIn(('Date', '1977-1-1'), result) + self.assertIn(('Disc', 1), result) self.assertIn(('Pos', 9), result) self.assertIn(('Id', 122), result) self.assertNotIn(('Comment', 'a comment'), result) From 5c0430ef4a3211e63ccec2c8bb8475527efdfa50 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 15:16:31 +0200 Subject: [PATCH 002/318] tests: Move models tests into a directory --- tests/{ => models}/test_models.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{ => models}/test_models.py (100%) diff --git a/tests/test_models.py b/tests/models/test_models.py similarity index 100% rename from tests/test_models.py rename to tests/models/test_models.py From 07912e1091cb1f17a55217f6d1204aefb7beeac8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 15:17:31 +0200 Subject: [PATCH 003/318] models: Add fields for supporting validation of models Feature makes use of python descriptors to hook in type checking and other validation when fields get set. --- mopidy/models.py | 121 ++++++++++++++++++++++ tests/models/test_fields.py | 194 ++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 tests/models/test_fields.py diff --git a/mopidy/models.py b/mopidy/models.py index 1ae26811..52af300d 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -2,6 +2,127 @@ from __future__ import absolute_import, unicode_literals import json +# TODO: split into base models, serialization and fields? + + +class Field(object): + def __init__(self, default=None, type=None, choices=None): + """ + Base field for use in :class:`ImmutableObject`. These fields are + responsible 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 + the object they are attached to allow it. + + Default values will be validated with the exception of :class:`None`. + + :param default: default value for field + :param type: if set the field value must be of this type + :param choices: if set the field value must be one of these + """ + self._name = None # Set by FieldMeta + self._choices = choices + self._default = default + self._type = type + + if self._default is not None: + self.validate(self._default) + + def validate(self, value): + """Validate and possibly modify the field value before assignment""" + if self._type and not isinstance(value, self._type): + raise TypeError('Expected %s to be a %s, not %r' % + (self._name, self._type, value)) + if self._choices and value not in self._choices: + raise TypeError('Expected %s to be a one of %s, not %r' % + (self._name, self._choices, value)) + return value + + def __get__(self, instance, owner): + if not instance: + return self + return instance.__dict__.get(self._name, self._default) + + def __set__(self, instance, value): + if value is None: + value = self._default + value = self.validate(value) + if value is not None: + instance.__dict__[self._name] = value + else: + self.__delete__(instance) + + def __delete__(self, instance): + instance.__dict__.pop(self._name, None) + + +class String(Field): + def __init__(self, default=None): + """ + Specialized :class:`Field` which is wired up for bytes and unicode. + + :param default: default value for field + """ + # TODO: normalize to unicode? + # TODO: only allow unicode? + # TODO: disallow empty strings? + super(String, self).__init__(type=basestring, default=default) + + +class Integer(Field): + def __init__(self, default=None, min=None, max=None): + """ + :class:`Field` for storing integer numbers. + + :param default: default value for field + :param min: if set the field value larger or equal to this value + :param max: if set the field value smaller or equal to this value + """ + self._min = min + self._max = max + super(Integer, self).__init__(type=(int, long), default=default) + + def validate(self, value): + value = super(Integer, self).validate(value) + if self._min is not None and value < self._min: + raise ValueError('Expected %s to be at least %d, not %d' % + (self._name, self._min, value)) + if self._max is not None and value > self._max: + raise ValueError('Expected %s to be at most %d, not %d' % + (self._name, self._max, value)) + return value + + +class Collection(Field): + def __init__(self, type, container=tuple): + """ + :class:`Field` for storing collections of a given type. + + :param type: all items stored in the collection must be of this type + :param container: the type to store the items in + """ + super(Collection, self).__init__(type=type, default=container()) + + def validate(self, value): + if isinstance(value, basestring): + raise TypeError('Expected %s to be a collection of %s, not %r' + % (self._name, self._type.__name__, value)) + for v in value: + if not isinstance(v, self._type): + raise TypeError('Expected %s to be a collection of %s, not %r' + % (self._name, self._type.__name__, value)) + return self._default.__class__(value) or None + + +class FieldOwner(type): + """Helper to automatically assign field names to descriptors.""" + def __new__(cls, name, bases, attrs): + for key, value in attrs.items(): + if isinstance(value, Field): + value._name = key + return super(FieldOwner, cls).__new__(cls, name, bases, attrs) + class ImmutableObject(object): diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py new file mode 100644 index 00000000..f2b55f01 --- /dev/null +++ b/tests/models/test_fields.py @@ -0,0 +1,194 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy.models import * # noqa: F403 + + +def create_instance(field): + """Create an instance of a dummy class for testing fields.""" + + class Dummy(object): + __metaclass__ = FieldOwner + attr = field + + return Dummy() + + +class FieldDescriptorTest(unittest.TestCase): + def test_raw_field_accesible_through_class(self): + field = Field() + instance = create_instance(field) + self.assertEqual(field, instance.__class__.attr) + + def test_field_knows_its_name(self): + instance = create_instance(Field()) + self.assertEqual('attr', instance.__class__.attr._name) + + def test_field_has_none_as_default(self): + instance = create_instance(Field()) + self.assertIsNone(instance.attr) + + def test_field_does_not_store_default_in_dict(self): + instance = create_instance(Field()) + self.assertNotIn('attr', instance.__dict__) + + def test_field_assigment_and_retrival(self): + instance = create_instance(Field()) + instance.attr = 1234 + self.assertEqual(1234, instance.attr) + self.assertEqual(1234, instance.__dict__['attr']) + + def test_field_can_be_reassigned(self): + instance = create_instance(Field()) + instance.attr = 1234 + instance.attr = 5678 + self.assertEqual(5678, instance.attr) + + def test_field_can_be_deleted(self): + instance = create_instance(Field()) + instance.attr = 1234 + del instance.attr + self.assertEqual(None, instance.attr) + self.assertNotIn('attr', instance.__dict__) + + def test_field_can_be_set_to_none(self): + instance = create_instance(Field()) + instance.attr = 1234 + instance.attr = None + self.assertEqual(None, instance.attr) + self.assertNotIn('attr', instance.__dict__) + + +class FieldTest(unittest.TestCase): + def test_default_handling(self): + instance = create_instance(Field(default=1234)) + self.assertEqual(1234, instance.attr) + + def test_type_checking(self): + instance = create_instance(Field(type=set)) + instance.attr = set() + + with self.assertRaises(TypeError): + instance.attr = 1234 + + def test_choices_checking(self): + instance = create_instance(Field(choices=(1, 2, 3))) + instance.attr = 1 + + with self.assertRaises(TypeError): + instance.attr = 4 + + def test_default_respects_type_check(self): + with self.assertRaises(TypeError): + create_instance(Field(type=int, default='123')) + + def test_default_respects_choices_check(self): + with self.assertRaises(TypeError): + create_instance(Field(choices=(1, 2, 3), default=5)) + + +class StringTest(unittest.TestCase): + def test_default_handling(self): + instance = create_instance(String(default='abc')) + self.assertEqual('abc', instance.attr) + + def test_str_allowed(self): + instance = create_instance(String()) + instance.attr = str('abc') + self.assertEqual(b'abc', instance.attr) + + def test_unicode_allowed(self): + instance = create_instance(String()) + instance.attr = unicode('abc') + self.assertEqual(u'abc', instance.attr) + + def test_other_disallowed(self): + instance = create_instance(String()) + with self.assertRaises(TypeError): + instance.attr = 1234 + + def test_empty_string(self): + instance = create_instance(String()) + instance.attr = '' + self.assertEqual('', instance.attr) + + +class IntegerTest(unittest.TestCase): + def test_default_handling(self): + instance = create_instance(Integer(default=1234)) + self.assertEqual(1234, instance.attr) + + def test_int_allowed(self): + instance = create_instance(Integer()) + instance.attr = int(123) + self.assertEqual(123, instance.attr) + + def test_long_allowed(self): + instance = create_instance(Integer()) + instance.attr = long(123) + self.assertEqual(123, instance.attr) + + def test_float_disallowed(self): + instance = create_instance(Integer()) + with self.assertRaises(TypeError): + instance.attr = 123.0 + + def test_numeric_string_disallowed(self): + instance = create_instance(Integer()) + with self.assertRaises(TypeError): + instance.attr = '123' + + def test_other_disallowed(self): + instance = create_instance(String()) + with self.assertRaises(TypeError): + instance.attr = tuple() + + def test_min_validation(self): + instance = create_instance(Integer(min=0)) + instance.attr = 0 + self.assertEqual(0, instance.attr) + + with self.assertRaises(ValueError): + instance.attr = -1 + + def test_max_validation(self): + instance = create_instance(Integer(max=10)) + instance.attr = 10 + self.assertEqual(10, instance.attr) + + with self.assertRaises(ValueError): + instance.attr = 11 + + +class CollectionTest(unittest.TestCase): + def test_container_instance_is_default(self): + instance = create_instance(Collection(type=int, container=frozenset)) + self.assertEqual(frozenset(), instance.attr) + + def test_empty_collection(self): + instance = create_instance(Collection(type=int, container=frozenset)) + instance.attr = [] + self.assertEqual(frozenset(), instance.attr) + self.assertNotIn('attr', instance.__dict__) + + def test_collection_gets_stored_in_container(self): + instance = create_instance(Collection(type=int, container=frozenset)) + instance.attr = [1, 2, 3] + self.assertEqual(frozenset([1, 2, 3]), instance.attr) + self.assertEqual(frozenset([1, 2, 3]), instance.__dict__['attr']) + + def test_collection_with_wrong_type(self): + instance = create_instance(Collection(type=int, container=frozenset)) + with self.assertRaises(TypeError): + instance.attr = [1, '2', 3] + + def test_collection_with_string(self): + instance = create_instance(Collection(type=int, container=frozenset)) + with self.assertRaises(TypeError): + instance.attr = '123' + + def test_strings_should_not_be_considered_a_collection(self): + instance = create_instance(Collection(type=str, container=tuple)) + with self.assertRaises(TypeError): + instance.attr = b'123' From 4faf4de7aada18d92c90366228dfdbe865f6aa76 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 15:18:56 +0200 Subject: [PATCH 004/318] models: Convert all models to using fields. --- mopidy/models.py | 125 ++++++++++++++++++++--------------------------- 1 file changed, 52 insertions(+), 73 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 52af300d..817b5ae3 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -128,12 +128,15 @@ class ImmutableObject(object): """ Superclass for immutable objects whose fields can only be modified via the - constructor. + constructor. Fields should be :class:`Field` instances to ensure type + safety in our models. :param kwargs: kwargs to set as fields on the object :type kwargs: any """ + __metaclass__ = FieldOwner + def __init__(self, *args, **kwargs): for key, value in kwargs.items(): if not hasattr(self, key) or callable(getattr(self, key)): @@ -142,7 +145,7 @@ class ImmutableObject(object): key) if value == getattr(self, key): continue # Don't explicitly set default values - self.__dict__[key] = value + super(ImmutableObject, self).__setattr__(key, value) def __setattr__(self, name, value): if name.startswith('_'): @@ -192,7 +195,7 @@ class ImmutableObject(object): :type values: dict :rtype: new instance of the model being copied """ - data = {} + data = {} # TODO: do we need public key handling now? for key in self.__dict__.keys(): public_key = key.lstrip('_') value = values.pop(public_key, self.__dict__[key]) @@ -207,7 +210,7 @@ class ImmutableObject(object): return self.__class__(**data) def serialize(self): - data = {} + data = {} # TODO: do we need public key handling now? data['__model__'] = self.__class__.__name__ for key in self.__dict__.keys(): public_key = key.lstrip('_') @@ -282,14 +285,10 @@ class Ref(ImmutableObject): """ #: The object URI. Read-only. - uri = None + uri = String() #: The object name. Read-only. - name = None - - #: The object type, e.g. "artist", "album", "track", "playlist", - #: "directory". Read-only. - type = None + name = String() #: Constant used for comparison with the :attr:`type` field. ALBUM = 'album' @@ -306,6 +305,10 @@ class Ref(ImmutableObject): #: Constant used for comparison with the :attr:`type` field. TRACK = 'track' + #: The object type, e.g. "artist", "album", "track", "playlist", + #: "directory". Read-only. + type = Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) + @classmethod def album(cls, **kwargs): """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" @@ -346,13 +349,13 @@ class Image(ImmutableObject): """ #: The image URI. Read-only. - uri = None + uri = String() #: Optional width of the image or :class:`None`. Read-only. - width = None + width = Integer(min=0) #: Optional height of the image or :class:`None`. Read-only. - height = None + height = Integer(min=0) class Artist(ImmutableObject): @@ -367,13 +370,13 @@ class Artist(ImmutableObject): """ #: The artist URI. Read-only. - uri = None + uri = String() #: The artist name. Read-only. - name = None + name = String() #: The MusicBrainz ID of the artist. Read-only. - musicbrainz_id = None + musicbrainz_id = String() class Album(ImmutableObject): @@ -398,37 +401,32 @@ class Album(ImmutableObject): """ #: The album URI. Read-only. - uri = None + uri = String() #: The album name. Read-only. - name = None + name = String() #: A set of album artists. Read-only. - artists = frozenset() + artists = Collection(type=Artist, container=frozenset) #: The number of tracks in the album. Read-only. - num_tracks = None + num_tracks = Integer(min=0) #: The number of discs in the album. Read-only. - num_discs = None + num_discs = Integer(min=0) #: The album release date. Read-only. - date = None + date = String() # TODO: add date type #: The MusicBrainz ID of the album. Read-only. - musicbrainz_id = None + musicbrainz_id = String() #: The album image URIs. Read-only. - images = frozenset() + images = Collection(type=basestring, container=frozenset) # XXX If we want to keep the order of images we shouldn't use frozenset() # as it doesn't preserve order. I'm deferring this issue until we got # actual usage of this field with more than one image. - def __init__(self, *args, **kwargs): - self.__dict__['artists'] = frozenset(kwargs.pop('artists', None) or []) - self.__dict__['images'] = frozenset(kwargs.pop('images', None) or []) - super(Album, self).__init__(*args, **kwargs) - class Track(ImmutableObject): @@ -466,61 +464,52 @@ class Track(ImmutableObject): """ #: The track URI. Read-only. - uri = None + uri = String() #: The track name. Read-only. - name = None + name = String() #: A set of track artists. Read-only. - artists = frozenset() + artists = Collection(type=Artist, container=frozenset) #: The track :class:`Album`. Read-only. - album = None + album = Field(type=Album) #: A set of track composers. Read-only. - composers = frozenset() + composers = Collection(type=Artist, container=frozenset) #: A set of track performers`. Read-only. - performers = frozenset() + performers = Collection(type=Artist, container=frozenset) #: The track genre. Read-only. - genre = None + genre = String() #: The track number in the album. Read-only. - track_no = None + track_no = Integer(min=0) #: The disc number in the album. Read-only. - disc_no = None + disc_no = Integer(min=0) #: The track release date. Read-only. - date = None + date = String() # TODO: add date type #: The track length in milliseconds. Read-only. - length = None + length = Integer(min=0) #: The track's bitrate in kbit/s. Read-only. - bitrate = None + bitrate = Integer(min=0) #: The track comment. Read-only. - comment = None + comment = String() #: The MusicBrainz ID of the track. Read-only. - musicbrainz_id = None + musicbrainz_id = String() #: Integer representing when the track was last modified. Exact meaning #: depends on source of track. For local files this is the modification #: time in milliseconds since Unix epoch. For other backends it could be an #: equivalent timestamp or simply a version counter. - last_modified = None - - def __init__(self, *args, **kwargs): - def get(key): - return frozenset(kwargs.pop(key, None) or []) - - self.__dict__['artists'] = get('artists') - self.__dict__['composers'] = get('composers') - self.__dict__['performers'] = get('performers') - super(Track, self).__init__(*args, **kwargs) + last_modified = Integer(min=0) class TlTrack(ImmutableObject): @@ -546,10 +535,10 @@ class TlTrack(ImmutableObject): """ #: The tracklist ID. Read-only. - tlid = None + tlid = Integer(min=0) #: The track. Read-only. - track = None + track = Field(type=Track) def __init__(self, *args, **kwargs): if len(args) == 2 and len(kwargs) == 0: @@ -577,23 +566,19 @@ class Playlist(ImmutableObject): """ #: The playlist URI. Read-only. - uri = None + uri = String() #: The playlist name. Read-only. - name = None + name = String() #: The playlist's tracks. Read-only. - tracks = tuple() + tracks = Collection(type=Track, container=tuple) #: The playlist modification time in milliseconds since Unix epoch. #: Read-only. #: #: Integer, or :class:`None` if unknown. - last_modified = None - - def __init__(self, *args, **kwargs): - self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or []) - super(Playlist, self).__init__(*args, **kwargs) + last_modified = Integer(min=0) # TODO: def insert(self, pos, track): ... ? @@ -617,19 +602,13 @@ class SearchResult(ImmutableObject): """ # The search result URI. Read-only. - uri = None + uri = String() # The tracks matching the search query. Read-only. - tracks = tuple() + tracks = Collection(type=Track, container=tuple) # The artists matching the search query. Read-only. - artists = tuple() + artists = Collection(type=Artist, container=tuple) # The albums matching the search query. Read-only. - albums = tuple() - - def __init__(self, *args, **kwargs): - self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or []) - self.__dict__['artists'] = tuple(kwargs.pop('artists', None) or []) - self.__dict__['albums'] = tuple(kwargs.pop('albums', None) or []) - super(SearchResult, self).__init__(*args, **kwargs) + albums = Collection(type=Album, container=tuple) From 73415ce60ffe65b038bb396e51d98972d2b85d4b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 15:21:38 +0200 Subject: [PATCH 005/318] models: Make sure del on attributes does not work --- mopidy/models.py | 3 +++ tests/models/test_models.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/mopidy/models.py b/mopidy/models.py index 817b5ae3..8f81b578 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -152,6 +152,9 @@ class ImmutableObject(object): return super(ImmutableObject, self).__setattr__(name, value) raise AttributeError('Object is immutable.') + def __delattr__(self, name): + raise AttributeError('Object is immutable.') + def __repr__(self): kwarg_pairs = [] for (key, value) in sorted(self.__dict__.items()): diff --git a/tests/models/test_models.py b/tests/models/test_models.py index e9a8f439..77383a6e 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -74,6 +74,12 @@ class RefTest(unittest.TestCase): with self.assertRaises(AttributeError): ref.name = None + # TODO: add these for the more of the models? + def test_del_name(self): + ref = Ref(name='foo') + with self.assertRaises(AttributeError): + del ref.name + def test_invalid_kwarg(self): with self.assertRaises(TypeError): Ref(foo='baz') From 0a2dff5a6a61301690902b48be523508df463dc6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 15:24:44 +0200 Subject: [PATCH 006/318] docs: Add model validation to changelog --- docs/changelog.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 605a30fe..b8f4f530 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,6 @@ Changelog This changelog is used to track all major changes to Mopidy. - v1.1.0 (UNRELEASED) =================== @@ -14,6 +13,12 @@ Core API - Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs`` as the query is no longer supported (PR: :issue:`1090`) +Models +------ + +- Added type checks and other sanity checks to model construction and + serialization. (Fixes: :issue:`865`) + Internal changes ---------------- From c8693a0591d41402a389fae7d9f63b3bf4dae3fc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 15:31:25 +0200 Subject: [PATCH 007/318] models: Simplify copy and serialize methods We don't need to worry about internal vs external naming when doing things via Fields. --- mopidy/models.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 8f81b578..5cea5723 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -198,26 +198,22 @@ class ImmutableObject(object): :type values: dict :rtype: new instance of the model being copied """ - data = {} # TODO: do we need public key handling now? - for key in self.__dict__.keys(): - public_key = key.lstrip('_') - value = values.pop(public_key, self.__dict__[key]) - data[public_key] = value + data = {} + for key, value in self.__dict__.items(): + data[key] = value for key in values.keys(): if hasattr(self, key): - value = values.pop(key) - data[key] = value + data[key] = values.pop(key) if values: + args = ', '.join(values) raise TypeError( - 'copy() got an unexpected keyword argument "%s"' % key) + 'copy() got an unexpected keyword argument "%s"' % args) return self.__class__(**data) def serialize(self): - data = {} # TODO: do we need public key handling now? + data = {} data['__model__'] = self.__class__.__name__ - for key in self.__dict__.keys(): - public_key = key.lstrip('_') - value = self.__dict__[key] + for key, value in self.__dict__.items(): if isinstance(value, (set, frozenset, list, tuple)): value = [ v.serialize() if isinstance(v, ImmutableObject) else v @@ -225,7 +221,7 @@ class ImmutableObject(object): elif isinstance(value, ImmutableObject): value = value.serialize() if not (isinstance(value, list) and len(value) == 0): - data[public_key] = value + data[key] = value return data From c375d772dd1170cc3f9334c70772cc2c8b7d88d7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 15:43:49 +0200 Subject: [PATCH 008/318] models: Store field keys in models --- mopidy/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index 5cea5723..0da4a51f 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -118,9 +118,12 @@ class Collection(Field): class FieldOwner(type): """Helper to automatically assign field names to descriptors.""" def __new__(cls, name, bases, attrs): + attrs['_fields'] = [] for key, value in attrs.items(): if isinstance(value, Field): + attrs['_fields'].append(key) value._name = key + attrs['_fields'].sort() return super(FieldOwner, cls).__new__(cls, name, bases, attrs) @@ -139,7 +142,7 @@ class ImmutableObject(object): def __init__(self, *args, **kwargs): for key, value in kwargs.items(): - if not hasattr(self, key) or callable(getattr(self, key)): + if key not in self._fields: raise TypeError( '__init__() got an unexpected keyword argument "%s"' % key) From 7eda0160ca3895e3f6781c5fb6eea16e0c8301b0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 15:44:24 +0200 Subject: [PATCH 009/318] models: Internal attrs are no longer needed --- mopidy/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 0da4a51f..9a22b846 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -151,8 +151,6 @@ class ImmutableObject(object): super(ImmutableObject, self).__setattr__(key, value) def __setattr__(self, name, value): - if name.startswith('_'): - return super(ImmutableObject, self).__setattr__(name, value) raise AttributeError('Object is immutable.') def __delattr__(self, name): From 2d03cd7290b95f0df97f2aa46dd4ceaf22d39211 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 16:22:59 +0200 Subject: [PATCH 010/318] models: Make fields handle unsetting defaults in __dict__ --- mopidy/models.py | 12 +++++------- tests/models/test_fields.py | 8 ++++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 9a22b846..cf11b4d2 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -45,13 +45,13 @@ class Field(object): return instance.__dict__.get(self._name, self._default) def __set__(self, instance, value): - if value is None: - value = self._default - value = self.validate(value) if value is not None: - instance.__dict__[self._name] = value - else: + value = self.validate(value) + + if value is None or value == self._default: self.__delete__(instance) + else: + instance.__dict__[self._name] = value def __delete__(self, instance): instance.__dict__.pop(self._name, None) @@ -146,8 +146,6 @@ class ImmutableObject(object): raise TypeError( '__init__() got an unexpected keyword argument "%s"' % key) - if value == getattr(self, key): - continue # Don't explicitly set default values super(ImmutableObject, self).__setattr__(key, value) def __setattr__(self, name, value): diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index f2b55f01..864373a9 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -59,6 +59,14 @@ class FieldDescriptorTest(unittest.TestCase): self.assertEqual(None, instance.attr) self.assertNotIn('attr', instance.__dict__) + def test_field_can_be_set_default(self): + default = object() + instance = create_instance(Field(default=default)) + instance.attr = 1234 + instance.attr = default + self.assertEqual(default, instance.attr) + self.assertNotIn('attr', instance.__dict__) + class FieldTest(unittest.TestCase): def test_default_handling(self): From f131ba4879f384487541afa792f3042da15ec980 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 16:28:32 +0200 Subject: [PATCH 011/318] models: Update copy to only validate new values. --- mopidy/models.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index cf11b4d2..a0194b1c 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -21,7 +21,7 @@ class Field(object): :param type: if set the field value must be of this type :param choices: if set the field value must be one of these """ - self._name = None # Set by FieldMeta + self._name = None # Set by FieldOwner self._choices = choices self._default = default self._type = type @@ -197,17 +197,14 @@ class ImmutableObject(object): :type values: dict :rtype: new instance of the model being copied """ - data = {} - for key, value in self.__dict__.items(): - data[key] = value - for key in values.keys(): - if hasattr(self, key): - data[key] = values.pop(key) - if values: - args = ', '.join(values) - raise TypeError( - 'copy() got an unexpected keyword argument "%s"' % args) - return self.__class__(**data) + other = self.__class__() + other.__dict__.update(self.__dict__.copy()) + for key, value in values.items(): + if key not in self._fields: + raise TypeError( + 'copy() got an unexpected keyword argument "%s"' % key) + super(ImmutableObject, other).__setattr__(key, value) + return other def serialize(self): data = {} From 86042132761e96062708ab283075b9f47c06b656 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Apr 2015 16:55:15 +0200 Subject: [PATCH 012/318] models: Remove __dict__.copy() that did not do anything --- mopidy/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index a0194b1c..eb6f4a58 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -198,7 +198,7 @@ class ImmutableObject(object): :rtype: new instance of the model being copied """ other = self.__class__() - other.__dict__.update(self.__dict__.copy()) + other.__dict__.update(self.__dict__) for key, value in values.items(): if key not in self._fields: raise TypeError( From 66771dec68fed31a6720cb3e348cbc26ba62ecc4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 3 Apr 2015 20:39:51 +0200 Subject: [PATCH 013/318] core: Update LibraryController to catch backend exceptions --- mopidy/core/library.py | 83 +++++++++++++++++++++++++------------- tests/core/test_library.py | 68 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 27 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index c787e013..4281b865 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -5,7 +5,6 @@ import logging import operator import urlparse -import pykka from mopidy.utils import deprecation @@ -70,9 +69,16 @@ class LibraryController(object): .. versionadded:: 0.18 """ if uri is None: + directories = set() backends = self.backends.with_library_browse.values() - unique_dirs = {b.library.root_directory.get() for b in backends} - return sorted(unique_dirs, key=operator.attrgetter('name')) + 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__) + return sorted(directories, key=operator.attrgetter('name')) scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_library_browse.get(scheme) @@ -96,11 +102,15 @@ class LibraryController(object): .. versionadded:: 1.0 """ - futures = [b.library.get_distinct(field, query) - for b in self.backends.with_library.values()] result = set() - for r in pykka.get_all(futures): - result.update(r) + 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__) return result def get_images(self, uris): @@ -118,15 +128,19 @@ class LibraryController(object): .. versionadded:: 1.0 """ - futures = [ - backend.library.get_images(backend_uris) + futures = { + backend: backend.library.get_images(backend_uris) for (backend, backend_uris) - in self._get_backends_to_uris(uris).items() if backend_uris] + in self._get_backends_to_uris(uris).items() if backend_uris} results = {uri: tuple() for uri in uris} - for r in pykka.get_all(futures): - for uri, images in r.items(): - results[uri] += tuple(images) + for backend, future in futures.items(): + try: + for uri, images in future.get().items(): + 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): @@ -171,19 +185,19 @@ class LibraryController(object): uris = [uri] futures = {} - result = {} - backends = self._get_backends_to_uris(uris) + result = {u: [] for u in uris} # TODO: lookup(uris) to backend APIs - for backend, backend_uris in backends.items(): - for u in backend_uris or []: - futures[u] = backend.library.lookup(u) + for backend, backend_uris in self._get_backends_to_uris(uris).items(): + for u in backend_uris: + futures[(backend, u)] = backend.library.lookup(u) - for u in uris: - if u in futures: - result[u] = futures[u].get() - else: - result[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__) if uri: return result[uri] @@ -199,11 +213,20 @@ class LibraryController(object): if uri is not None: backend = self._get_backend(uri) if backend: - backend.library.refresh(uri).get() + try: + backend.library.refresh(uri).get() + except Exception: + logger.exception('%s backend caused an exception.', + backend.actor_ref.actor_class.__name__) else: - futures = [b.library.refresh(uri) - for b in self.backends.with_library.values()] - pykka.get_all(futures) + futures = {b: b.library.refresh(uri) + for b in self.backends.with_library.values()} + for backend, future in futures.items(): + try: + 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): """ @@ -273,6 +296,12 @@ class LibraryController(object): logger.warning( '%s does not implement library.search() with "exact" ' 'support. Please upgrade it.', backend_name) + except LookupError: + raise + except Exception: + logger.exception('%s backend caused an exception.', + backend.actor_ref.actor_class.__name__) + return [r for r in results if r] diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 8d2195a2..ba6b859e 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -419,3 +419,71 @@ class LegacyFindExactToSearchLibraryTest(unittest.TestCase): self.backend.library.search.return_value.get.side_effect = TypeError self.core.library.search(query={'any': ['a']}, exact=True) # We are just testing that this doesn't fail. + + +@mock.patch('mopidy.core.library.logger') +class BackendFailuresCoreLibraryTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + dummy_root = Ref.directory(uri='dummy:directory', name='dummy') + + self.library = mock.Mock(spec=backend.LibraryProvider) + self.library.root_directory.get.return_value = dummy_root + + self.backend = mock.Mock() + self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' + self.backend.uri_schemes.get.return_value = ['dummy'] + self.backend.library = self.library + + self.core = core.Core(mixer=None, backends=[self.backend]) + + def test_browse_backend_get_root_exception_gets_ignored(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_through(self, logger): + # TODO: is this behavior desired? + self.library.browse.return_value.get.side_effect = Exception + with self.assertRaises(Exception): + self.core.library.browse('dummy:directory') + + def test_get_distinct_backend_exception_gets_ignored(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): + self.library.get_images.return_value.get.side_effect = Exception + self.assertEqual( + {'dummy:/1': tuple()}, self.core.library.get_images(['dummy:/1'])) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_lookup_backend_exceptiosn_gets_ignores(self, logger): + self.library.lookup.return_value.get.side_effect = Exception + self.assertEqual( + {'dummy:/1': []}, self.core.library.lookup(uris=['dummy:/1'])) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_refresh_backend_exception_gets_ignored(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): + 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): + 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): + # 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']}) From 50f68064be1eb6f174d1ba7fb76cde3f8bb39be4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 3 Apr 2015 20:40:30 +0200 Subject: [PATCH 014/318] core: Update PlaylistsController to catch backend exceptions --- mopidy/core/playlists.py | 38 +++++++++++++++++++++++++---------- tests/core/test_playlists.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 2c997d84..500713a3 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -3,8 +3,6 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse -import pykka - from mopidy.core import listener from mopidy.models import Playlist from mopidy.utils import deprecation @@ -32,17 +30,21 @@ class PlaylistsController(object): .. versionadded:: 1.0 """ futures = { - b.actor_ref.actor_class.__name__: b.playlists.as_list() - for b in set(self.backends.with_playlists.values())} + backend: backend.playlists.as_list() + for backend in set(self.backends.with_playlists.values())} results = [] - for backend_name, future in futures.items(): + for backend, future in futures.items(): try: results.extend(future.get()) except NotImplementedError: + backend_name = backend.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 @@ -191,6 +193,8 @@ class PlaylistsController(object): else: 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): """ Refresh the playlists in :attr:`playlists`. @@ -204,15 +208,27 @@ class PlaylistsController(object): :type uri_scheme: string """ if uri_scheme is None: - futures = [b.playlists.refresh() - for b in self.backends.with_playlists.values()] - pykka.get_all(futures) - listener.CoreListener.send('playlists_loaded') + futures = {b: b.playlists.refresh() + for b in self.backends.with_playlists.values()} + playlists_loaded = False + for backend, future in futures.items(): + try: + 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') else: backend = self.backends.with_playlists.get(uri_scheme, None) if backend: - backend.playlists.refresh().get() - listener.CoreListener.send('playlists_loaded') + try: + backend.playlists.refresh().get() + listener.CoreListener.send('playlists_loaded') + except Exception: + logger.exception('%s backend caused an exception.', + backend.actor_ref.actor_class.__name__) def save(self, playlist): """ diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 4ca3d6df..1ccc1815 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -279,3 +279,42 @@ class DeprecatedGetPlaylistsTest(BasePlaylistsTest): self.assertEqual(len(result[0].tracks), 0) self.assertEqual(result[1].name, 'B') self.assertEqual(len(result[1].tracks), 0) + + +@mock.patch('mopidy.core.playlists.logger') +class BackendFailuresCorePlaylistsTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.playlists = mock.Mock(spec=backend.PlaylistsProvider) + + self.backend = mock.Mock() + self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' + self.backend.uri_schemes.get.return_value = ['dummy'] + self.backend.playlists = self.playlists + + 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 + 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? + self.playlists.get_items.return_value.get.side_effect = Exception + with self.assertRaises(Exception): + self.core.playlists.get_items('dummy:/1') + + @mock.patch('mopidy.core.listener.CoreListener.send') + def test_refresh_backend_exception_gets_ignored(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): + 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') From 34a88792f24d5be625e2bc72761aa19ca68982ca Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 3 Apr 2015 21:28:09 +0200 Subject: [PATCH 015/318] core: Create a unified code path for refresh calls --- mopidy/core/library.py | 34 +++++++++++++++---------------- mopidy/core/playlists.py | 43 ++++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 4281b865..324786ad 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -210,23 +210,23 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - if uri is not None: - backend = self._get_backend(uri) - if backend: - try: - backend.library.refresh(uri).get() - except Exception: - logger.exception('%s backend caused an exception.', - backend.actor_ref.actor_class.__name__) - else: - futures = {b: b.library.refresh(uri) - for b in self.backends.with_library.values()} - for backend, future in futures.items(): - try: - future.get() - except Exception: - logger.exception('%s backend caused an exception.', - backend.actor_ref.actor_class.__name__) + futures = {} + backends = {} + uri_scheme = urlparse.urlparse(uri).scheme if uri else None + + for backend_scheme, backend in self.backends.with_playlists.items(): + backends.setdefault(backend, set()).add(backend_scheme) + + for backend, backend_schemes in backends.items(): + if uri_scheme is None or uri_scheme in backend_schemes: + futures[backend] = backend.library.refresh(uri) + + for backend, future in futures.items(): + try: + 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): """ diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 500713a3..62001517 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -207,28 +207,27 @@ class PlaylistsController(object): :param uri_scheme: limit to the backend matching the URI scheme :type uri_scheme: string """ - if uri_scheme is None: - futures = {b: b.playlists.refresh() - for b in self.backends.with_playlists.values()} - playlists_loaded = False - for backend, future in futures.items(): - try: - 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') - else: - backend = self.backends.with_playlists.get(uri_scheme, None) - if backend: - try: - backend.playlists.refresh().get() - listener.CoreListener.send('playlists_loaded') - except Exception: - logger.exception('%s backend caused an exception.', - backend.actor_ref.actor_class.__name__) + futures = {} + backends = {} + playlists_loaded = False + + for backend_scheme, backend in self.backends.with_playlists.items(): + backends.setdefault(backend, set()).add(backend_scheme) + + for backend, backend_schemes in backends.items(): + if uri_scheme is None or uri_scheme in backend_schemes: + futures[backend] = backend.playlists.refresh() + + for backend, future in futures.items(): + try: + 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') def save(self, playlist): """ From 5fdd5d08989a35a2284d960940fb0a284a5cc353 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 3 Apr 2015 21:36:46 +0200 Subject: [PATCH 016/318] docs: Add core changes to changelog --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 605a30fe..d161400e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,14 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.1 (unreleased) +=================== + +Core +---- + +- Update core controllers to handle backend exceptions in all calls that rely + on multiple backends. (Issue: :issue:`667`) v1.1.0 (UNRELEASED) =================== From 9b442e15637f2b0605ef6ed38b7f812cc7381855 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 6 Apr 2015 23:27:46 +0200 Subject: [PATCH 017/318] review: Address review comments --- docs/api/models.rst | 2 + mopidy/models.py | 72 ++++++++++++++++++++---------------- tests/models/test_fields.py | 9 ++++- tests/mpd/test_translator.py | 4 +- 4 files changed, 52 insertions(+), 35 deletions(-) diff --git a/docs/api/models.rst b/docs/api/models.rst index 23a08002..7192c284 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -76,6 +76,8 @@ Data model helpers .. autoclass:: mopidy.models.ImmutableObject +.. autoclass:: mopidy.models.Field + .. autoclass:: mopidy.models.ModelJSONEncoder .. autofunction:: mopidy.models.model_json_decoder diff --git a/mopidy/models.py b/mopidy/models.py index eb6f4a58..45961f30 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -6,21 +6,23 @@ import json class Field(object): + + """ + Base field for use in :class:`ImmutableObject`. 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 + the object they are attached to allow it. + + Default values will be validated with the exception of :class:`None`. + + :param default: default value for field + :param type: if set the field value must be of this type + :param choices: if set the field value must be one of these + """ + def __init__(self, default=None, type=None, choices=None): - """ - Base field for use in :class:`ImmutableObject`. These fields are - responsible 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 - the object they are attached to allow it. - - Default values will be validated with the exception of :class:`None`. - - :param default: default value for field - :param type: if set the field value must be of this type - :param choices: if set the field value must be one of these - """ self._name = None # Set by FieldOwner self._choices = choices self._default = default @@ -58,12 +60,14 @@ class Field(object): class String(Field): - def __init__(self, default=None): - """ - Specialized :class:`Field` which is wired up for bytes and unicode. - :param default: default value for field - """ + """ + Specialized :class:`Field` which is wired up for bytes and unicode. + + :param default: default value for field + """ + + def __init__(self, default=None): # TODO: normalize to unicode? # TODO: only allow unicode? # TODO: disallow empty strings? @@ -71,14 +75,16 @@ class String(Field): class Integer(Field): - def __init__(self, default=None, min=None, max=None): - """ - :class:`Field` for storing integer numbers. - :param default: default value for field - :param min: if set the field value larger or equal to this value - :param max: if set the field value smaller or equal to this value + """ + :class:`Field` for storing integer numbers. + + :param default: default value for field + :param min: field value must be larger or equal to this value when set + :param max: field value must be smaller or equal to this value when set """ + + def __init__(self, default=None, min=None, max=None): self._min = min self._max = max super(Integer, self).__init__(type=(int, long), default=default) @@ -95,13 +101,15 @@ class Integer(Field): class Collection(Field): - def __init__(self, type, container=tuple): - """ - :class:`Field` for storing collections of a given type. - :param type: all items stored in the collection must be of this type - :param container: the type to store the items in - """ + """ + :class:`Field` for storing collections of a given type. + + :param type: all items stored in the collection must be of this type + :param container: the type to store the items in + """ + + def __init__(self, type, container=tuple): super(Collection, self).__init__(type=type, default=container()) def validate(self, value): @@ -116,7 +124,9 @@ class Collection(Field): class FieldOwner(type): + """Helper to automatically assign field names to descriptors.""" + def __new__(cls, name, bases, attrs): attrs['_fields'] = [] for key, value in attrs.items(): diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index 864373a9..5347b83c 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -101,14 +101,19 @@ class StringTest(unittest.TestCase): instance = create_instance(String(default='abc')) self.assertEqual('abc', instance.attr) - def test_str_allowed(self): + def test_native_str_allowed(self): instance = create_instance(String()) instance.attr = str('abc') + self.assertEqual('abc', instance.attr) + + def test_bytes_allowed(self): + instance = create_instance(String()) + instance.attr = b'abc' self.assertEqual(b'abc', instance.attr) def test_unicode_allowed(self): instance = create_instance(String()) - instance.attr = unicode('abc') + instance.attr = u'abc' self.assertEqual(u'abc', instance.attr) def test_other_disallowed(self): diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 3a9b00d8..4e1baf0e 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -19,7 +19,7 @@ class TrackMpdFormatTest(unittest.TestCase): composers=[Artist(name='a composer')], performers=[Artist(name='a performer')], genre='a genre', - date='1977-1-1', + date='1977-01-01', disc_no=1, comment='a comment', length=137000, @@ -72,7 +72,7 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertIn(('Performer', 'a performer'), result) self.assertIn(('Genre', 'a genre'), result) self.assertIn(('Track', '7/13'), result) - self.assertIn(('Date', '1977-1-1'), result) + self.assertIn(('Date', '1977-01-01'), result) self.assertIn(('Disc', 1), result) self.assertIn(('Pos', 9), result) self.assertIn(('Id', 122), result) From 56eb08ea7e187b9ecdc0df4efd5dc1f9b7d0b09b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 6 Apr 2015 23:30:19 +0200 Subject: [PATCH 018/318] docs: Update changelog after rebase --- docs/changelog.rst | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d161400e..4595587d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,14 +4,6 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.1 (unreleased) -=================== - -Core ----- - -- Update core controllers to handle backend exceptions in all calls that rely - on multiple backends. (Issue: :issue:`667`) v1.1.0 (UNRELEASED) =================== @@ -22,6 +14,9 @@ Core API - Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs`` as the query is no longer supported (PR: :issue:`1090`) +- Update core controllers to handle backend exceptions in all calls that rely + on multiple backends. (Issue: :issue:`667`) + Internal changes ---------------- From 4bb953f6250b4298cc86f33c0686e0bd0435584d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Apr 2015 00:09:31 +0200 Subject: [PATCH 019/318] docs: Fix missing markup --- mopidy/core/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index c787e013..ae6e63ad 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -41,8 +41,8 @@ class LibraryController(object): Browse directories and tracks at the given ``uri``. ``uri`` is a string which represents some directory belonging to a - backend. To get the intial root directories for backends pass None as - the URI. + backend. To get the intial root directories for backends pass + :class:`None` as the URI. Returns a list of :class:`mopidy.models.Ref` objects for the directories and tracks at the given ``uri``. From f743c7ed29c56b247e3b39163fa3588fff54a048 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Apr 2015 00:09:44 +0200 Subject: [PATCH 020/318] m3u: Add todo --- mopidy/m3u/playlists.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index c09eccdf..eaa3d980 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -76,6 +76,8 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): 'Loaded %d M3U playlists from %s', len(playlists), self._playlists_dir) + # TODO Trigger playlists_loaded event? + def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' assert playlist.uri in self._playlists, \ From df1636e8147096e8b4eb46d67a003a9fb4bb7a47 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Apr 2015 00:42:39 +0200 Subject: [PATCH 021/318] docs: Remove GStreamer mixer example --- mopidy/ext.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index f5f15058..3122611f 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -89,14 +89,7 @@ class Extension(object): the ``frontend`` and ``backend`` registry keys. This method can also be used for other setup tasks not involving the - extension registry. For example, to register custom GStreamer - elements:: - - def setup(self, registry): - from .mixer import SoundspotMixer - gobject.type_register(SoundspotMixer) - gst.element_register( - SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) + extension registry. :param registry: the extension registry :type registry: :class:`Registry` From 20b457cc4a60ea1ba03438b2de93ae3257a7ae18 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Apr 2015 01:05:45 +0200 Subject: [PATCH 022/318] Move gobject check from __init__ to __main__ Related to #1068 --- mopidy/__init__.py | 16 ---------------- mopidy/__main__.py | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 388bb9f0..8322de32 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, print_function, unicode_literals import platform import sys -import textwrap import warnings @@ -11,21 +10,6 @@ if not (2, 7) <= sys.version_info < (3,): 'ERROR: Mopidy requires Python 2.7, but found %s.' % platform.python_version()) -try: - import gobject # noqa -except ImportError: - print(textwrap.dedent(""" - ERROR: The gobject Python package was not found. - - Mopidy requires GStreamer (and GObject) to work. These are C libraries - with a number of dependencies themselves, and cannot be installed with - the regular Python tools like pip. - - Please see http://docs.mopidy.com/en/latest/installation/ for - instructions on how to install the required dependencies. - """)) - raise - warnings.filterwarnings('ignore', 'could not open display') diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 96e10e18..9ec9769f 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -4,8 +4,23 @@ import logging import os import signal import sys +import textwrap + +try: + import gobject # noqa +except ImportError: + print(textwrap.dedent(""" + ERROR: The gobject Python package was not found. + + Mopidy requires GStreamer (and GObject) to work. These are C libraries + with a number of dependencies themselves, and cannot be installed with + the regular Python tools like pip. + + Please see http://docs.mopidy.com/en/latest/installation/ for + instructions on how to install the required dependencies. + """)) + raise -import gobject gobject.threads_init() try: From 7bda4f835f0055240461da88489cc31cef1992a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Apr 2015 23:43:32 +0200 Subject: [PATCH 023/318] xdg: Add XDG dir utils --- mopidy/utils/xdg.py | 56 +++++++++++++++++++++++++++++++++ tests/utils/test_xdg.py | 69 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 mopidy/utils/xdg.py create mode 100644 tests/utils/test_xdg.py diff --git a/mopidy/utils/xdg.py b/mopidy/utils/xdg.py new file mode 100644 index 00000000..ffc6de22 --- /dev/null +++ b/mopidy/utils/xdg.py @@ -0,0 +1,56 @@ +from __future__ import absolute_import, unicode_literals + +import ConfigParser +import io +import os + + +def get_dirs(): + """Returns a dict of all the known XDG Base Directories for the current user. + + The keys ``XDG_CACHE_DIR``, ``XDG_CONFIG_DIR``, and ``XDG_DATA_DIR`` is + always available. + + Additional keys, like ``XDG_MUSIC_DIR``, may be available if the + ``$XDG_CONFIG_DIR/user-dirs.dirs`` file exists and is parseable. + + See http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html + for the XDG Base Directory specification. + """ + + dirs = { + 'XDG_CACHE_DIR': ( + os.environ.get('XDG_CACHE_HOME') or + os.path.expanduser(b'~/.cache')), + 'XDG_CONFIG_DIR': ( + os.environ.get('XDG_CONFIG_HOME') or + os.path.expanduser(b'~/.config')), + 'XDG_DATA_DIR': ( + os.environ.get('XDG_DATA_HOME') or + os.path.expanduser(b'~/.local/share')), + } + + dirs.update(_get_user_dirs(dirs['XDG_CONFIG_DIR'])) + + return dirs + + +def _get_user_dirs(xdg_config_dir): + dirs_file = os.path.join(xdg_config_dir, 'user-dirs.dirs') + + if not os.path.exists(dirs_file): + return {} + + with open(dirs_file, 'rb') as fh: + data = fh.read() + + data = b'[XDG_USER_DIRS]\n' + data + data = data.replace(b'$HOME', os.path.expanduser(b'~')) + data = data.replace(b'"', b'') + + config = ConfigParser.RawConfigParser() + config.readfp(io.BytesIO(data)) + + return { + k.decode('utf-8').upper(): os.path.abspath(v) + for k, v in config.items('XDG_USER_DIRS') if v is not None} diff --git a/tests/utils/test_xdg.py b/tests/utils/test_xdg.py new file mode 100644 index 00000000..eab595a4 --- /dev/null +++ b/tests/utils/test_xdg.py @@ -0,0 +1,69 @@ +from __future__ import unicode_literals + +import os + +import mock + +import pytest + +from mopidy.utils import xdg + + +@pytest.yield_fixture +def environ(): + patcher = mock.patch.dict(os.environ, clear=True) + yield patcher.start() + patcher.stop() + + +def test_cache_dir_default(environ): + assert xdg.get_dirs()['XDG_CACHE_DIR'] == os.path.expanduser(b'~/.cache') + + +def test_cache_dir_from_env(environ): + os.environ['XDG_CACHE_HOME'] = '/foo/bar' + + assert xdg.get_dirs()['XDG_CACHE_DIR'] == '/foo/bar' + + +def test_config_dir_default(environ): + assert xdg.get_dirs()['XDG_CONFIG_DIR'] == os.path.expanduser(b'~/.config') + + +def test_config_dir_from_env(environ): + os.environ['XDG_CONFIG_HOME'] = '/foo/bar' + + assert xdg.get_dirs()['XDG_CONFIG_DIR'] == '/foo/bar' + + +def test_data_dir_default(environ): + assert xdg.get_dirs()['XDG_DATA_DIR'] == os.path.expanduser( + b'~/.local/share') + + +def test_data_dir_from_env(environ): + os.environ['XDG_DATA_HOME'] = '/foo/bar' + + assert xdg.get_dirs()['XDG_DATA_DIR'] == '/foo/bar' + + +def test_user_dirs(environ, tmpdir): + os.environ['XDG_CONFIG_HOME'] = str(tmpdir) + + with open(os.path.join(str(tmpdir), 'user-dirs.dirs'), 'wb') as fh: + fh.write('# Some comments\n') + fh.write('XDG_MUSIC_DIR="$HOME/Music2"\n') + + result = xdg.get_dirs() + + assert result['XDG_MUSIC_DIR'] == os.path.expanduser(b'~/Music2') + assert 'XDG_DOWNLOAD_DIR' not in result + + +def test_user_dirs_when_no_dirs_file(environ, tmpdir): + os.environ['XDG_CONFIG_HOME'] = str(tmpdir) + + result = xdg.get_dirs() + + assert 'XDG_MUSIC_DIR' not in result + assert 'XDG_DOWNLOAD_DIR' not in result From 9becb26f6016067795d0c23a795d56ed091d10fc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Apr 2015 23:44:40 +0200 Subject: [PATCH 024/318] path: Get XDG dirs without using glib Related to #1068 --- mopidy/utils/path.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index e845cd95..37b6cdb1 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -8,25 +8,15 @@ import threading import urllib import urlparse -import glib - from mopidy import compat, exceptions from mopidy.compat import queue -from mopidy.utils import encoding +from mopidy.utils import encoding, xdg logger = logging.getLogger(__name__) -XDG_DIRS = { - 'XDG_CACHE_DIR': glib.get_user_cache_dir(), - 'XDG_CONFIG_DIR': glib.get_user_config_dir(), - 'XDG_DATA_DIR': glib.get_user_data_dir(), - 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), -} - -# XDG_MUSIC_DIR can be none, so filter out any bad data. -XDG_DIRS = dict((k, v) for k, v in XDG_DIRS.items() if v is not None) +XDG_DIRS = xdg.get_dirs() def get_or_create_dir(dir_path): From 168c10448b5142654b3e0be8fa2c21388d7e5b29 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 7 Apr 2015 23:59:36 +0200 Subject: [PATCH 025/318] models: Use copy.copy for creating copies --- mopidy/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 45961f30..4f8ae6fe 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import copy import json # TODO: split into base models, serialization and fields? @@ -207,8 +208,7 @@ class ImmutableObject(object): :type values: dict :rtype: new instance of the model being copied """ - other = self.__class__() - other.__dict__.update(self.__dict__) + other = copy.copy(self) for key, value in values.items(): if key not in self._fields: raise TypeError( From 299bc722ce978cda3e09c23f234158a118e71294 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 8 Apr 2015 00:10:39 +0200 Subject: [PATCH 026/318] listener: Move glib import into function Related to #1068 --- mopidy/listener.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/listener.py b/mopidy/listener.py index 410558ac..35bd8b73 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -2,14 +2,18 @@ from __future__ import absolute_import, unicode_literals import logging -import gobject - import pykka logger = logging.getLogger(__name__) def send_async(cls, event, **kwargs): + # This file is imported by mopidy.backends, which again is imported by all + # backend extensions. By importing modules that are not easily installable + # close to their use, we make some extensions able to run their tests in a + # virtualenv with global site-packages disabled. + import gobject + gobject.idle_add(lambda: send(cls, event, **kwargs)) From 08fd99ffdbe73e9a005351e14600bc243c3e588e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 00:11:23 +0200 Subject: [PATCH 027/318] models: Add Identifer type which interns values. This gives some moderate memory saving since the values are used in multiple places. --- mopidy/models.py | 25 +++++++++++++++---------- tests/models/test_models.py | 20 ++++++++++---------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 4f8ae6fe..e936e22c 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -75,6 +75,11 @@ class String(Field): super(String, self).__init__(type=basestring, default=default) +class Identifier(String): + def validate(self, value): + return intern(str(super(Identifier, self).validate(value))) + + class Integer(Field): """ @@ -290,7 +295,7 @@ class Ref(ImmutableObject): """ #: The object URI. Read-only. - uri = String() + uri = Identifier() #: The object name. Read-only. name = String() @@ -354,7 +359,7 @@ class Image(ImmutableObject): """ #: The image URI. Read-only. - uri = String() + uri = Identifier() #: Optional width of the image or :class:`None`. Read-only. width = Integer(min=0) @@ -375,13 +380,13 @@ class Artist(ImmutableObject): """ #: The artist URI. Read-only. - uri = String() + uri = Identifier() #: The artist name. Read-only. name = String() #: The MusicBrainz ID of the artist. Read-only. - musicbrainz_id = String() + musicbrainz_id = Identifier() class Album(ImmutableObject): @@ -406,7 +411,7 @@ class Album(ImmutableObject): """ #: The album URI. Read-only. - uri = String() + uri = Identifier() #: The album name. Read-only. name = String() @@ -424,7 +429,7 @@ class Album(ImmutableObject): date = String() # TODO: add date type #: The MusicBrainz ID of the album. Read-only. - musicbrainz_id = String() + musicbrainz_id = Identifier() #: The album image URIs. Read-only. images = Collection(type=basestring, container=frozenset) @@ -469,7 +474,7 @@ class Track(ImmutableObject): """ #: The track URI. Read-only. - uri = String() + uri = Identifier() #: The track name. Read-only. name = String() @@ -508,7 +513,7 @@ class Track(ImmutableObject): comment = String() #: The MusicBrainz ID of the track. Read-only. - musicbrainz_id = String() + musicbrainz_id = Identifier() #: Integer representing when the track was last modified. Exact meaning #: depends on source of track. For local files this is the modification @@ -571,7 +576,7 @@ class Playlist(ImmutableObject): """ #: The playlist URI. Read-only. - uri = String() + uri = Identifier() #: The playlist name. Read-only. name = String() @@ -607,7 +612,7 @@ class SearchResult(ImmutableObject): """ # The search result URI. Read-only. - uri = String() + uri = Identifier() # The tracks matching the search query. Read-only. tracks = Collection(type=Track, container=tuple) diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 77383a6e..7e0d6bb9 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -86,7 +86,7 @@ class RefTest(unittest.TestCase): def test_repr_without_results(self): self.assertEqual( - "Ref(name=u'foo', type=u'artist', uri=u'uri')", + "Ref(name=u'foo', type=u'artist', uri='uri')", repr(Ref(uri='uri', name='foo', type='artist'))) def test_serialize_without_results(self): @@ -200,7 +200,7 @@ class ArtistTest(unittest.TestCase): def test_repr(self): self.assertEqual( - "Artist(name=u'name', uri=u'uri')", + "Artist(name=u'name', uri='uri')", repr(Artist(uri='uri', name='name'))) def test_serialize(self): @@ -365,12 +365,12 @@ class AlbumTest(unittest.TestCase): def test_repr_without_artists(self): self.assertEqual( - "Album(name=u'name', uri=u'uri')", + "Album(name=u'name', uri='uri')", repr(Album(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEqual( - "Album(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", + "Album(artists=[Artist(name=u'foo')], name=u'name', uri='uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): @@ -609,12 +609,12 @@ class TrackTest(unittest.TestCase): def test_repr_without_artists(self): self.assertEqual( - "Track(name=u'name', uri=u'uri')", + "Track(name=u'name', uri='uri')", repr(Track(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEqual( - "Track(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", + "Track(artists=[Artist(name=u'foo')], name=u'name', uri='uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): @@ -844,7 +844,7 @@ class TlTrackTest(unittest.TestCase): def test_repr(self): self.assertEqual( - "TlTrack(tlid=123, track=Track(uri=u'uri'))", + "TlTrack(tlid=123, track=Track(uri='uri'))", repr(TlTrack(tlid=123, track=Track(uri='uri')))) def test_serialize(self): @@ -977,12 +977,12 @@ class PlaylistTest(unittest.TestCase): def test_repr_without_tracks(self): self.assertEqual( - "Playlist(name=u'name', uri=u'uri')", + "Playlist(name=u'name', uri='uri')", repr(Playlist(uri='uri', name='name'))) def test_repr_with_tracks(self): self.assertEqual( - "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri=u'uri')", + "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri='uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) def test_serialize_without_tracks(self): @@ -1114,7 +1114,7 @@ class SearchResultTest(unittest.TestCase): def test_repr_without_results(self): self.assertEqual( - "SearchResult(uri=u'uri')", + "SearchResult(uri='uri')", repr(SearchResult(uri='uri'))) def test_serialize_without_results(self): From ea52e8ffdd140e64254cb32f4ae12dc5935ba60b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 8 Apr 2015 00:15:12 +0200 Subject: [PATCH 028/318] docs: Add #1068 fix to changelog Fixes #1068 --- docs/changelog.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b8f4f530..60d011d7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,12 @@ Internal changes - Tests have been cleaned up to stop using deprecated APIs where feasible. (Partial fix: :issue:`1083`, PR: :issue:`1090`) +- It is now possible to import :mod:`mopidy.backends` without having GObject or + GStreamer installed. In other words, a lot of backend extensions should now + be able to run tests in a virtualenv with global site-packages disabled. This + removes a lot of potential error sources. (Fixes: :issue:`1068`, PR: + :issue:`1115`) + v1.0.1 (UNRELEASED) =================== From 0b8e9426b55932ca9bf671f4c0d51c6404f7b1ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 8 Apr 2015 00:29:55 +0200 Subject: [PATCH 029/318] xdg: Fix review comments --- mopidy/utils/xdg.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/xdg.py b/mopidy/utils/xdg.py index ffc6de22..adb43f39 100644 --- a/mopidy/utils/xdg.py +++ b/mopidy/utils/xdg.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -import ConfigParser +import ConfigParser as configparser import io import os @@ -36,6 +36,16 @@ def get_dirs(): def _get_user_dirs(xdg_config_dir): + """Returns a dict of XDG dirs read from + ``$XDG_CONFIG_HOME/user-dirs.dirs``. + + This is used at import time for most users of :mod:`mopidy`. By rolling our + own implementation instead of using :meth:`glib.get_user_special_dir` we + make it possible for many extensions to run their test suites, which are + importing parts of :mod:`mopidy`, in a virtualenv with global site-packages + disabled, and thus no :mod:`glib` available. + """ + dirs_file = os.path.join(xdg_config_dir, 'user-dirs.dirs') if not os.path.exists(dirs_file): @@ -48,7 +58,7 @@ def _get_user_dirs(xdg_config_dir): data = data.replace(b'$HOME', os.path.expanduser(b'~')) data = data.replace(b'"', b'') - config = ConfigParser.RawConfigParser() + config = configparser.RawConfigParser() config.readfp(io.BytesIO(data)) return { From 0fee1b4b1133eaa01d45cbce49f131d1f359adf3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 00:30:53 +0200 Subject: [PATCH 030/318] models: Switch to slots to reduce memory usage per instance --- mopidy/models.py | 33 ++++++++++++++++++++++----------- tests/models/test_fields.py | 13 +++++-------- tests/models/test_models.py | 2 +- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index e936e22c..35f1f9eb 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -45,7 +45,7 @@ class Field(object): def __get__(self, instance, owner): if not instance: return self - return instance.__dict__.get(self._name, self._default) + return getattr(instance, '_' + self._name, self._default) def __set__(self, instance, value): if value is not None: @@ -54,10 +54,11 @@ class Field(object): if value is None or value == self._default: self.__delete__(instance) else: - instance.__dict__[self._name] = value + setattr(instance, '_' + self._name, value) def __delete__(self, instance): - instance.__dict__.pop(self._name, None) + if hasattr(instance, '_' + self._name): + delattr(instance, '_' + self._name) class String(Field): @@ -134,12 +135,14 @@ class FieldOwner(type): """Helper to automatically assign field names to descriptors.""" def __new__(cls, name, bases, attrs): - attrs['_fields'] = [] + fields = {} for key, value in attrs.items(): if isinstance(value, Field): - attrs['_fields'].append(key) + fields[key] = '_' + key value._name = key - attrs['_fields'].sort() + + attrs['_fields'] = fields + attrs['__slots__'] = ['_' + field for field in fields] return super(FieldOwner, cls).__new__(cls, name, bases, attrs) @@ -165,14 +168,23 @@ class ImmutableObject(object): super(ImmutableObject, self).__setattr__(key, value) def __setattr__(self, name, value): + if name in self.__slots__: + return super(ImmutableObject, self).__setattr__(name, value) 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.') + def _items(self): + for field, key in self._fields.items(): + if hasattr(self, key): + yield field, getattr(self, key) + def __repr__(self): kwarg_pairs = [] - for (key, value) in sorted(self.__dict__.items()): + for key, value in sorted(self._items()): if isinstance(value, (frozenset, tuple)): if not value: continue @@ -185,15 +197,14 @@ class ImmutableObject(object): def __hash__(self): hash_sum = 0 - for key, value in self.__dict__.items(): + 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__): return False - - return self.__dict__ == other.__dict__ + return dict(self._items()) == dict(other._items()) def __ne__(self, other): return not self.__eq__(other) @@ -224,7 +235,7 @@ class ImmutableObject(object): def serialize(self): data = {} data['__model__'] = self.__class__.__name__ - for key, value in self.__dict__.items(): + for key, value in self._items(): if isinstance(value, (set, frozenset, list, tuple)): value = [ v.serialize() if isinstance(v, ImmutableObject) else v diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index 5347b83c..ff863487 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -29,15 +29,14 @@ class FieldDescriptorTest(unittest.TestCase): instance = create_instance(Field()) self.assertIsNone(instance.attr) - def test_field_does_not_store_default_in_dict(self): + def test_field_does_not_store_default(self): instance = create_instance(Field()) - self.assertNotIn('attr', instance.__dict__) + self.assertFalse(hasattr(instance, '_attr')) def test_field_assigment_and_retrival(self): instance = create_instance(Field()) instance.attr = 1234 self.assertEqual(1234, instance.attr) - self.assertEqual(1234, instance.__dict__['attr']) def test_field_can_be_reassigned(self): instance = create_instance(Field()) @@ -50,14 +49,14 @@ class FieldDescriptorTest(unittest.TestCase): instance.attr = 1234 del instance.attr self.assertEqual(None, instance.attr) - self.assertNotIn('attr', instance.__dict__) + self.assertFalse(hasattr(instance, '_attr')) def test_field_can_be_set_to_none(self): instance = create_instance(Field()) instance.attr = 1234 instance.attr = None self.assertEqual(None, instance.attr) - self.assertNotIn('attr', instance.__dict__) + self.assertFalse(hasattr(instance, '_attr')) def test_field_can_be_set_default(self): default = object() @@ -65,7 +64,7 @@ class FieldDescriptorTest(unittest.TestCase): instance.attr = 1234 instance.attr = default self.assertEqual(default, instance.attr) - self.assertNotIn('attr', instance.__dict__) + self.assertFalse(hasattr(instance, '_attr')) class FieldTest(unittest.TestCase): @@ -183,13 +182,11 @@ class CollectionTest(unittest.TestCase): instance = create_instance(Collection(type=int, container=frozenset)) instance.attr = [] self.assertEqual(frozenset(), instance.attr) - self.assertNotIn('attr', instance.__dict__) def test_collection_gets_stored_in_container(self): instance = create_instance(Collection(type=int, container=frozenset)) instance.attr = [1, 2, 3] self.assertEqual(frozenset([1, 2, 3]), instance.attr) - self.assertEqual(frozenset([1, 2, 3]), instance.__dict__['attr']) def test_collection_with_wrong_type(self): instance = create_instance(Collection(type=int, container=frozenset)) diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 7e0d6bb9..8f72dd34 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -55,7 +55,7 @@ class GenericCopyTest(unittest.TestCase): def test_copying_track_to_remove(self): track = Track(name='foo').copy(name=None) - self.assertEqual(track.__dict__, Track().__dict__) + self.assertFalse(hasattr(track, '_name')) class RefTest(unittest.TestCase): From 86481b1d504ee1f25ac1a8a5dadbe58ee8e12161 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 00:34:02 +0200 Subject: [PATCH 031/318] models: Shortcut case where copy didn't change anything We no longer copy in this case and will just give you the same instance back. --- mopidy/models.py | 2 ++ tests/models/test_models.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index 35f1f9eb..af409570 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -224,6 +224,8 @@ class ImmutableObject(object): :type values: dict :rtype: new instance of the model being copied """ + if not values: + return self other = copy.copy(self) for key, value in values.items(): if key not in self._fields: diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 8f72dd34..ef484fa9 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -12,7 +12,7 @@ class GenericCopyTest(unittest.TestCase): def compare(self, orig, other): self.assertEqual(orig, other) - self.assertNotEqual(id(orig), id(other)) + self.assertEqual(id(orig), id(other)) def test_copying_track(self): track = Track() From dd270ab87b87239fe4d42c0f851a5d9472ce75b9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 00:36:03 +0200 Subject: [PATCH 032/318] models: Stop using globals to get model names in JSON decoding. --- mopidy/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index af409570..c12da719 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -282,13 +282,13 @@ def model_json_decoder(dct): """ if '__model__' in dct: + models = {c.__name__: c for c in ImmutableObject.__subclasses__()} model_name = dct.pop('__model__') - cls = globals().get(model_name, None) - if issubclass(cls, ImmutableObject): + if model_name in models: kwargs = {} for key, value in dct.items(): kwargs[key] = value - return cls(**kwargs) + return models[model_name](**kwargs) return dct From b7375323e902d0dbbb0ff1aea75753a616a037cc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 01:14:56 +0200 Subject: [PATCH 033/318] models: Memoize identical instances automatically This combined with the previous changes has brought the memory use for a 14k track test-set down from about 75MB to 17MB or so. Note that this does however, mean that copy is now lying to us as it does not such thing whenever it can avoid it. --- mopidy/models.py | 23 ++++++++++++++++++----- tests/models/test_fields.py | 2 +- tests/models/test_models.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index c12da719..97b524ea 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import copy import json +import weakref # TODO: split into base models, serialization and fields? @@ -24,7 +25,7 @@ class Field(object): """ def __init__(self, default=None, type=None, choices=None): - self._name = None # Set by FieldOwner + self._name = None # Set by ImmutableObjectMeta self._choices = choices self._default = default self._type = type @@ -130,7 +131,7 @@ class Collection(Field): return self._default.__class__(value) or None -class FieldOwner(type): +class ImmutableObjectMeta(type): """Helper to automatically assign field names to descriptors.""" @@ -142,8 +143,20 @@ class FieldOwner(type): value._name = key attrs['_fields'] = fields + attrs['_instances'] = weakref.WeakValueDictionary() attrs['__slots__'] = ['_' + field for field in fields] - return super(FieldOwner, cls).__new__(cls, name, bases, attrs) + + for base in bases: + if '__weakref__' in getattr(base, '__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): @@ -157,7 +170,7 @@ class ImmutableObject(object): :type kwargs: any """ - __metaclass__ = FieldOwner + __metaclass__ = ImmutableObjectMeta def __init__(self, *args, **kwargs): for key, value in kwargs.items(): @@ -232,7 +245,7 @@ class ImmutableObject(object): raise TypeError( 'copy() got an unexpected keyword argument "%s"' % key) super(ImmutableObject, other).__setattr__(key, value) - return other + return self._instances.setdefault(weakref.ref(other), other) def serialize(self): data = {} diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index ff863487..1bf46b7f 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -9,7 +9,7 @@ def create_instance(field): """Create an instance of a dummy class for testing fields.""" class Dummy(object): - __metaclass__ = FieldOwner + __metaclass__ = ImmutableObjectMeta attr = field return Dummy() diff --git a/tests/models/test_models.py b/tests/models/test_models.py index ef484fa9..90701b14 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -8,6 +8,22 @@ from mopidy.models import ( TlTrack, Track, model_json_decoder) +class CachingTest(unittest.TestCase): + + def test_same_instance(self): + self.assertIs(Track(), Track()) + + def test_same_instance_with_values(self): + self.assertIs(Track(uri='test'), Track(uri='test')) + + def test_different_instance_with_different_values(self): + self.assertIsNot(Track(uri='test1'), Track(uri='test2')) + + def test_different_instance_with_copy(self): + t = Track(uri='test1') + self.assertIsNot(t, t.copy(uri='test2')) + + class GenericCopyTest(unittest.TestCase): def compare(self, orig, other): From 05244f7e60e95676898209a6c96899d74b6c982a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 01:41:50 +0200 Subject: [PATCH 034/318] models: Deprecate copy and add replace method Changed as with the memoization copy was lying, so replace is a better name. --- docs/api/models.rst | 5 +-- mopidy/models.py | 34 ++++++++++++++----- mopidy/utils/deprecation.py | 3 ++ tests/models/test_models.py | 66 ++++++++++++++++++------------------- 4 files changed, 65 insertions(+), 43 deletions(-) diff --git a/docs/api/models.rst b/docs/api/models.rst index 7192c284..d2d8ec0a 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -8,7 +8,7 @@ and immutable. In other words, they can only be set through the class constructor during instance creation. If you want to modify a model, use the -:meth:`~mopidy.models.ImmutableObject.copy` method. It accepts keyword +:meth:`~mopidy.models.ImmutableObject.replace` method. It accepts keyword arguments for the parts of the model you want to change, and copies the rest of the data from the model you call it on. Example:: @@ -16,7 +16,7 @@ the data from the model you call it on. Example:: >>> track1 = Track(name='Christmas Carol', length=171) >>> track1 Track(artists=[], length=171, name='Christmas Carol') - >>> track2 = track1.copy(length=37) + >>> track2 = track1.replace(length=37) >>> track2 Track(artists=[], length=37, name='Christmas Carol') >>> track1 @@ -75,6 +75,7 @@ Data model helpers ================== .. autoclass:: mopidy.models.ImmutableObject + :members: .. autoclass:: mopidy.models.Field diff --git a/mopidy/models.py b/mopidy/models.py index 97b524ea..bd449a89 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -4,6 +4,8 @@ import copy import json import weakref +from mopidy.utils import deprecation + # TODO: split into base models, serialization and fields? @@ -166,6 +168,10 @@ class ImmutableObject(object): 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. + :param kwargs: kwargs to set as fields on the object :type kwargs: any """ @@ -224,23 +230,35 @@ class ImmutableObject(object): def copy(self, **values): """ - Copy the model with ``field`` updated to new value. + .. deprecated:: 1.1 + Use :meth:`replace` instead. Note that we no longer return copies. + """ + 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').copy(name='bar') + Track(name='foo').replace(name='bar') # Return an album with a new number of tracks - Album(num_tracks=2).copy(num_tracks=5) + Album(num_tracks=2).replace(num_tracks=5) - :param values: the model fields to modify - :type values: dict - :rtype: new instance of the model being copied + Note that internally we memoize heavily to keep memory usage down given + our overly repetitive data structures. So you might get an existing + instance if it contains the same values. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + :rtype: instance of the model with replaced fields """ - if not values: + if not kwargs: return self other = copy.copy(self) - for key, value in values.items(): + for key, value in kwargs.items(): if key not in self._fields: raise TypeError( 'copy() got an unexpected keyword argument "%s"' % key) diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 57042347..4f26b41b 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -40,6 +40,9 @@ _MESSAGES = { 'tracklist.add() "tracks" argument is deprecated', 'core.tracklist.add:uri_arg': 'tracklist.add() "uri" argument is deprecated', + + 'models.immutable.copy': + 'ImmutableObject.copy() is deprecated, use ImmutableObject.replace()', } diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 90701b14..0407056c 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -19,58 +19,58 @@ class CachingTest(unittest.TestCase): def test_different_instance_with_different_values(self): self.assertIsNot(Track(uri='test1'), Track(uri='test2')) - def test_different_instance_with_copy(self): + def test_different_instance_with_replace(self): t = Track(uri='test1') - self.assertIsNot(t, t.copy(uri='test2')) + self.assertIsNot(t, t.replace(uri='test2')) -class GenericCopyTest(unittest.TestCase): +class GenericReplaceTest(unittest.TestCase): def compare(self, orig, other): self.assertEqual(orig, other) self.assertEqual(id(orig), id(other)) - def test_copying_track(self): + def test_replace_track(self): track = Track() - self.compare(track, track.copy()) + self.compare(track, track.replace()) - def test_copying_artist(self): + def test_replace_artist(self): artist = Artist() - self.compare(artist, artist.copy()) + self.compare(artist, artist.replace()) - def test_copying_album(self): + def test_replace_album(self): album = Album() - self.compare(album, album.copy()) + self.compare(album, album.replace()) - def test_copying_playlist(self): + def test_replace_playlist(self): playlist = Playlist() - self.compare(playlist, playlist.copy()) + self.compare(playlist, playlist.replace()) - def test_copying_track_with_basic_values(self): + def test_replace_track_with_basic_values(self): track = Track(name='foo', uri='bar') - copy = track.copy(name='baz') - self.assertEqual('baz', copy.name) - self.assertEqual('bar', copy.uri) + other = track.replace(name='baz') + self.assertEqual('baz', other.name) + self.assertEqual('bar', other.uri) - def test_copying_track_with_missing_values(self): + def test_replace_track_with_missing_values(self): track = Track(uri='bar') - copy = track.copy(name='baz') - self.assertEqual('baz', copy.name) - self.assertEqual('bar', copy.uri) + other = track.replace(name='baz') + self.assertEqual('baz', other.name) + self.assertEqual('bar', other.uri) - def test_copying_track_with_private_internal_value(self): + def test_replace_track_with_private_internal_value(self): artist1 = Artist(name='foo') artist2 = Artist(name='bar') track = Track(artists=[artist1]) - copy = track.copy(artists=[artist2]) - self.assertIn(artist2, copy.artists) + other = track.replace(artists=[artist2]) + self.assertIn(artist2, other.artists) - def test_copying_track_with_invalid_key(self): + def test_replace_track_with_invalid_key(self): with self.assertRaises(TypeError): - Track().copy(invalid_key=True) + Track().replace(invalid_key=True) - def test_copying_track_to_remove(self): - track = Track(name='foo').copy(name=None) + def test_replace_track_to_remove(self): + track = Track(name='foo').replace(name=None) self.assertFalse(hasattr(track, '_name')) @@ -209,7 +209,7 @@ class ArtistTest(unittest.TestCase): def test_invalid_kwarg_with_name_matching_method(self): with self.assertRaises(TypeError): - Artist(copy='baz') + Artist(replace='baz') with self.assertRaises(TypeError): Artist(serialize='baz') @@ -816,9 +816,9 @@ class TrackTest(unittest.TestCase): self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) - def test_copy_can_reset_to_default_value(self): + def test_replace_can_reset_to_default_value(self): track1 = Track(name='name1') - track2 = Track(name='name1', album=Album()).copy(album=None) + track2 = Track(name='name1', album=Album()).replace(album=None) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) @@ -943,7 +943,7 @@ class PlaylistTest(unittest.TestCase): playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.copy(uri='another uri') + new_playlist = playlist.replace(uri='another uri') self.assertEqual(new_playlist.uri, 'another uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), tracks) @@ -955,7 +955,7 @@ class PlaylistTest(unittest.TestCase): playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.copy(name='another name') + new_playlist = playlist.replace(name='another name') self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'another name') self.assertEqual(list(new_playlist.tracks), tracks) @@ -968,7 +968,7 @@ class PlaylistTest(unittest.TestCase): uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] - new_playlist = playlist.copy(tracks=new_tracks) + new_playlist = playlist.replace(tracks=new_tracks) self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), new_tracks) @@ -981,7 +981,7 @@ class PlaylistTest(unittest.TestCase): playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.copy(last_modified=new_last_modified) + new_playlist = playlist.replace(last_modified=new_last_modified) self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), tracks) From c85323bfa0f9029f03a219ccad3d59044a7d022c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 01:47:57 +0200 Subject: [PATCH 035/318] docs: Add memory improvements --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b8f4f530..3f195ff1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,6 +19,11 @@ Models - Added type checks and other sanity checks to model construction and serialization. (Fixes: :issue:`865`) +- Memory usage for models has been greatly improved. We now have a lower + overhead per instance by using slots, intern identifiers and automatically + reuse instances. For the test data set this was developed against, a library + of ~14000 tracks, went from needing ~75MB to ~17MB. (Fixes: :issue:`348`) + Internal changes ---------------- From 81b005d2979a2d5ebf43f286e6f135507559f590 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 8 Apr 2015 20:29:15 +0200 Subject: [PATCH 036/318] Add mopidy-dleyna. --- docs/ext/backends.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 17e2a7ca..d6ed65cd 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -57,6 +57,15 @@ Provides a backend for browsing the Internet radio channels from the `Dirble `_ directory. +Mopidy-dLeyna +============= + +https://github.com/tkem/mopidy-dleyna + +Provides a backend for playing music from Digital Media Servers using +the `dLeyna `_ D-Bus interface. + + Mopidy-GMusic ============= From 928b8df08c4445034f995aca2b0cb8210d399104 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 21:10:21 +0200 Subject: [PATCH 037/318] core: Explain why we let LookupError through for search --- mopidy/core/library.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 35a43501..b226a378 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -297,6 +297,9 @@ class LibraryController(object): '%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.', From 511cf4e32618e2db83cc23627b762664ec7de72f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 21:12:07 +0200 Subject: [PATCH 038/318] core: Catch exceptions when browsing in backends Also splits browse into to method to better distinguish the two possible code paths. --- mopidy/core/library.py | 35 +++++++++++++++++++++-------------- tests/core/test_library.py | 5 ++--- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index b226a378..35ed02c1 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -68,23 +68,30 @@ class LibraryController(object): .. versionadded:: 0.18 """ - if uri is None: - directories = set() - 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__) - return sorted(directories, key=operator.attrgetter('name')) + return self._roots() if uri is None else self._browse(uri) + def _roots(self): + directories = set() + 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__) + return sorted(directories, key=operator.attrgetter('name')) + + def _browse(self, uri): scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_library_browse.get(scheme) - if not backend: - return [] - return backend.library.browse(uri).get() + try: + if backend: + return backend.library.browse(uri).get() # TODO: sort? + except Exception: + logger.exception('%s backend caused an exception.', + backend.actor_ref.actor_class.__name__) + return [] def get_distinct(self, field, query=None): """ diff --git a/tests/core/test_library.py b/tests/core/test_library.py index ba6b859e..6cbb00b3 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -444,10 +444,9 @@ class BackendFailuresCoreLibraryTest(unittest.TestCase): logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_browse_backend_browse_uri_exception_gets_through(self, logger): - # TODO: is this behavior desired? self.library.browse.return_value.get.side_effect = Exception - with self.assertRaises(Exception): - self.core.library.browse('dummy:directory') + 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): self.library.get_distinct.return_value.get.side_effect = Exception From e5f59495fcf89285df5997a435d0bf20657ecbbe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 21:18:18 +0200 Subject: [PATCH 039/318] core: Update refresh test case to fail on multiple calls to same backend --- tests/core/test_library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 6cbb00b3..9cb2588d 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -190,8 +190,8 @@ class CoreLibraryTest(BaseCoreLibraryTest): def test_refresh_without_uri_calls_all_backends(self): self.core.library.refresh() - self.library1.refresh.assert_called_once_with(None) - self.library2.refresh.assert_called_twice_with(None) + self.library1.refresh.return_value.get.assert_called_once_with() + self.library2.refresh.return_value.get.assert_called_once_with() def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') From 2cc91c0a7fc95349012d9927a1e3918ffe453491 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 23:13:07 +0200 Subject: [PATCH 040/318] core: Fix review comments for PR#1111 --- mopidy/core/library.py | 6 ++++-- tests/core/test_library.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 35ed02c1..6fc1ce38 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -68,7 +68,9 @@ class LibraryController(object): .. versionadded:: 0.18 """ - return self._roots() if uri is None else self._browse(uri) + if uri is None: + return self._roots() + return self._browse(uri) def _roots(self): directories = set() @@ -87,7 +89,7 @@ class LibraryController(object): backend = self.backends.with_library_browse.get(scheme) try: if backend: - return backend.library.browse(uri).get() # TODO: sort? + return backend.library.browse(uri).get() except Exception: logger.exception('%s backend caused an exception.', backend.actor_ref.actor_class.__name__) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9cb2588d..89f3b284 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -443,7 +443,7 @@ class BackendFailuresCoreLibraryTest(unittest.TestCase): self.assertEqual([], self.core.library.browse(None)) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') - def test_browse_backend_browse_uri_exception_gets_through(self, logger): + def test_browse_backend_browse_uri_exception_gets_ignored(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') From bbfa722af345a536741d04ecea3a588acef0acfe Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Wed, 8 Apr 2015 23:19:36 +0200 Subject: [PATCH 041/318] docs: Update to Archlinux instructions --- docs/installation/arch.rst | 17 ++++++++--------- docs/installation/source.rst | 2 +- docs/running.rst | 4 ++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index f8492fdf..c5675403 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -1,20 +1,19 @@ .. _arch-install: -**************************** -Arch Linux: Install from AUR -**************************** +********************************** +Arch Linux: Install from community +********************************** If you are running Arch Linux, you can install Mopidy using the -`mopidy `_ package found in AUR. +`mopidy `_ package found in ``community``. -#. To install Mopidy with all dependencies, you can use - for example `yaourt `_:: +#. To install Mopidy with all dependencies, you can use:: - yaourt -S mopidy + pacman -S mopidy To upgrade Mopidy to future releases, just upgrade your system using:: - yaourt -Syua + pacman -Syu #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. @@ -24,7 +23,7 @@ Installing extensions ===================== If you want to use any Mopidy extensions, like Spotify support or Last.fm -scrobbling, AUR also has `packages for lots of Mopidy extensions +scrobbling, AUR has `packages for lots of Mopidy extensions `_. You can also install any Mopidy extension directly from PyPI with ``pip``. To diff --git a/docs/installation/source.rst b/docs/installation/source.rst index c2018984..204cc1df 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -5,7 +5,7 @@ Install from source ******************* If you are on Linux, but can't install :ref:`from the APT archive -` or :ref:`from AUR `, you can install Mopidy +` or :ref:`from the Arch Linux repository `, you can install Mopidy from PyPI using the ``pip`` installer. If you are looking to contribute or wish to install from source using ``git`` diff --git a/docs/running.rst b/docs/running.rst index af37d481..2c7ced21 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -33,8 +33,8 @@ Init scripts `_. For more details, see the :ref:`debian` section of the docs. -- The ``mopidy`` package in `Arch Linux AUR - `__ comes with a systemd init +- The ``mopidy`` package in `Arch Linux + `__ comes with a systemd init script. - A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch From fb0e4dc7a193e4ffefd8ff4ff69e72aa0aa33323 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 23:20:59 +0200 Subject: [PATCH 042/318] models: Assign slots from fields --- mopidy/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index bd449a89..3dd2c67c 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -145,8 +145,8 @@ class ImmutableObjectMeta(type): value._name = key attrs['_fields'] = fields + attrs['__slots__'] = fields.values() attrs['_instances'] = weakref.WeakValueDictionary() - attrs['__slots__'] = ['_' + field for field in fields] for base in bases: if '__weakref__' in getattr(base, '__slots__', []): From c4fc33e5eac3253f2e83b50f88194e6900ca04f2 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Wed, 8 Apr 2015 23:19:36 +0200 Subject: [PATCH 043/318] docs: Update to Archlinux instructions (cherry picked from commit bbfa722af345a536741d04ecea3a588acef0acfe) --- docs/installation/arch.rst | 17 ++++++++--------- docs/installation/source.rst | 2 +- docs/running.rst | 4 ++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index f8492fdf..c5675403 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -1,20 +1,19 @@ .. _arch-install: -**************************** -Arch Linux: Install from AUR -**************************** +********************************** +Arch Linux: Install from community +********************************** If you are running Arch Linux, you can install Mopidy using the -`mopidy `_ package found in AUR. +`mopidy `_ package found in ``community``. -#. To install Mopidy with all dependencies, you can use - for example `yaourt `_:: +#. To install Mopidy with all dependencies, you can use:: - yaourt -S mopidy + pacman -S mopidy To upgrade Mopidy to future releases, just upgrade your system using:: - yaourt -Syua + pacman -Syu #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. @@ -24,7 +23,7 @@ Installing extensions ===================== If you want to use any Mopidy extensions, like Spotify support or Last.fm -scrobbling, AUR also has `packages for lots of Mopidy extensions +scrobbling, AUR has `packages for lots of Mopidy extensions `_. You can also install any Mopidy extension directly from PyPI with ``pip``. To diff --git a/docs/installation/source.rst b/docs/installation/source.rst index c2018984..204cc1df 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -5,7 +5,7 @@ Install from source ******************* If you are on Linux, but can't install :ref:`from the APT archive -` or :ref:`from AUR `, you can install Mopidy +` or :ref:`from the Arch Linux repository `, you can install Mopidy from PyPI using the ``pip`` installer. If you are looking to contribute or wish to install from source using ``git`` diff --git a/docs/running.rst b/docs/running.rst index af37d481..2c7ced21 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -33,8 +33,8 @@ Init scripts `_. For more details, see the :ref:`debian` section of the docs. -- The ``mopidy`` package in `Arch Linux AUR - `__ comes with a systemd init +- The ``mopidy`` package in `Arch Linux + `__ comes with a systemd init script. - A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch From 2cb2750b39a87aa0c9300bf1f6044bc7b6ec2749 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Apr 2015 23:23:55 +0200 Subject: [PATCH 044/318] models: Simplify JSON decoder code --- mopidy/models.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 3dd2c67c..4b268474 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -313,13 +313,11 @@ def model_json_decoder(dct): """ if '__model__' in dct: + # TODO: move models to a global constant once we split this module models = {c.__name__: c for c in ImmutableObject.__subclasses__()} model_name = dct.pop('__model__') if model_name in models: - kwargs = {} - for key, value in dct.items(): - kwargs[key] = value - return models[model_name](**kwargs) + return models[model_name](**dct) return dct From c5c9bc39e1597722b719f4f6571795ea1f5a7edb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Apr 2015 07:30:26 +0200 Subject: [PATCH 045/318] core: Get access to config Needed for #997 --- mopidy/commands.py | 7 ++++--- mopidy/core/actor.py | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index e00fca3f..2414348b 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -278,7 +278,7 @@ class RootCommand(Command): mixer = self.start_mixer(config, mixer_class) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) - core = self.start_core(mixer, backends, audio) + core = self.start_core(config, mixer, backends, audio) self.start_frontends(config, frontend_classes, core) loop.run() except (exceptions.BackendError, @@ -365,9 +365,10 @@ class RootCommand(Command): return backends - def start_core(self, mixer, backends, audio): + def start_core(self, config, mixer, backends, audio): logger.info('Starting Mopidy core') - return Core.start(mixer=mixer, backends=backends, audio=audio).proxy() + return Core.start( + config=config, mixer=mixer, backends=backends, audio=audio).proxy() def start_frontends(self, config, frontend_classes, core): logger.info( diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 475a8cb8..d2454f64 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -46,9 +46,11 @@ class Core( """The tracklist controller. An instance of :class:`mopidy.core.TracklistController`.""" - def __init__(self, mixer=None, backends=None, audio=None): + def __init__(self, config=None, mixer=None, backends=None, audio=None): super(Core, self).__init__() + self._config = config + self.backends = Backends(backends) self.library = LibraryController(backends=self.backends, core=self) From c77b63f4c8dcbd495ec88d24709571b7061d1b62 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 10 Apr 2015 23:28:00 +0200 Subject: [PATCH 046/318] audio: Add main method to scanner for quick testing --- mopidy/audio/scan.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 3880d91a..58793905 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -160,3 +160,28 @@ def _process(pipeline, timeout_ms): timeout -= clock.get_time() - start raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) + + +if __name__ == '__main__': + import os + import sys + + import gobject + + from mopidy.utils import path + + gobject.threads_init() + + scanner = Scanner(5000) + for uri in sys.argv[1:]: + if not gst.uri_is_valid(uri): + uri = path.path_to_uri(os.path.abspath(uri)) + try: + result = scanner.scan(uri) + for key in ('uri', 'mime', 'duration', 'seekable'): + print '%-20s %s' % (key, getattr(result, key)) + print 'tags' + for tag, value in result.tags.items(): + print '%-20s %s' % (tag, value) + except exceptions.ScannerError as error: + print '%s: %s' % (uri, error) From 05c4af017ba2891ee2bcfa56951421fbcfaad76a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 10 Apr 2015 23:31:05 +0200 Subject: [PATCH 047/318] audio: Create fakesinks on the fly for scanner pads This makes us correctly handle say when someone gives us a movie, or something else that seems to have multiple things that can be encoded internally. --- mopidy/audio/scan.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 58793905..d9e5ae94 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -70,17 +70,16 @@ def _setup_pipeline(uri, proxy_config=None): typefind = gst.element_factory_make('typefind') decodebin = gst.element_factory_make('decodebin2') - sink = gst.element_factory_make('fakesink') pipeline = gst.element_factory_make('pipeline') - pipeline.add_many(src, typefind, decodebin, sink) + pipeline.add_many(src, typefind, decodebin) gst.element_link_many(src, typefind, decodebin) if proxy_config: utils.setup_proxy(src, proxy_config) decodebin.set_property('caps', _RAW_AUDIO) - decodebin.connect('pad-added', _pad_added, sink) + decodebin.connect('pad-added', _pad_added, pipeline) typefind.connect('have-type', _have_type, decodebin) return pipeline @@ -92,8 +91,13 @@ def _have_type(element, probability, caps, decodebin): element.get_bus().post(msg) -def _pad_added(element, pad, sink): - return pad.link(sink.get_pad('sink')) +def _pad_added(element, pad, pipeline): + sink = gst.element_factory_make('fakesink') + sink.set_property('sync', False) + + pipeline.add(sink) + sink.sync_state_with_parent() + pad.link(sink.get_pad('sink')) def _start_pipeline(pipeline): From dfaa3f143391c2d856d9b7d9b69e1841cc0e3f71 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 11 Apr 2015 00:03:20 +0200 Subject: [PATCH 048/318] audio: Have scanner tell us if we found decodeable audio --- mopidy/audio/scan.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index d9e5ae94..822277eb 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -14,7 +14,7 @@ from mopidy.utils import encoding _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description _Result = collections.namedtuple( - 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime')) + 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable')) _RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') @@ -51,14 +51,14 @@ class Scanner(object): try: _start_pipeline(pipeline) - tags, mime = _process(pipeline, self._timeout_ms) + tags, mime, have_audio = _process(pipeline, self._timeout_ms) duration = _query_duration(pipeline) seekable = _query_seekable(pipeline) finally: pipeline.set_state(gst.STATE_NULL) del pipeline - return _Result(uri, tags, duration, seekable, mime) + return _Result(uri, tags, duration, seekable, mime, have_audio) # Turns out it's _much_ faster to just create a new pipeline for every as @@ -87,8 +87,9 @@ def _setup_pipeline(uri, proxy_config=None): def _have_type(element, probability, caps, decodebin): decodebin.set_property('sink-caps', caps) - msg = gst.message_new_application(element, caps.get_structure(0)) - element.get_bus().post(msg) + struct = gst.Structure('have-type') + struct['caps'] = caps.get_structure(0) + element.get_bus().post(gst.message_new_application(element, struct)) def _pad_added(element, pad, pipeline): @@ -99,6 +100,10 @@ def _pad_added(element, pad, pipeline): sink.sync_state_with_parent() pad.link(sink.get_pad('sink')) + if pad.get_caps().is_subset(_RAW_AUDIO): + struct = gst.Structure('have-audio') + element.get_bus().post(gst.message_new_application(element, struct)) + def _start_pipeline(pipeline): if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL: @@ -127,7 +132,7 @@ def _process(pipeline, timeout_ms): clock = pipeline.get_clock() bus = pipeline.get_bus() timeout = timeout_ms * gst.MSECOND - tags, mime, missing_description = {}, None, None + tags, mime, have_audio, missing_description = {}, None, False, None types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) @@ -143,19 +148,22 @@ def _process(pipeline, timeout_ms): missing_description = encoding.locale_decode( _missing_plugin_desc(message)) elif message.type == gst.MESSAGE_APPLICATION: - mime = message.structure.get_name() - if mime.startswith('text/') or mime == 'application/xml': - return tags, mime + if message.structure.get_name() == 'have-type': + mime = message.structure['caps'].get_name() + if mime.startswith('text/') or mime == 'application/xml': + return tags, mime, have_audio + elif message.structure.get_name() == 'have-audio': + have_audio = True elif message.type == gst.MESSAGE_ERROR: error = encoding.locale_decode(message.parse_error()[0]) if missing_description: error = '%s (%s)' % (missing_description, error) raise exceptions.ScannerError(error) elif message.type == gst.MESSAGE_EOS: - return tags, mime + return tags, mime, have_audio elif message.type == gst.MESSAGE_ASYNC_DONE: if message.src == pipeline: - return tags, mime + return tags, mime, have_audio elif message.type == gst.MESSAGE_TAG: taglist = message.parse_tag() # Note that this will only keep the last tag. @@ -182,7 +190,7 @@ if __name__ == '__main__': uri = path.path_to_uri(os.path.abspath(uri)) try: result = scanner.scan(uri) - for key in ('uri', 'mime', 'duration', 'seekable'): + for key in ('uri', 'mime', 'duration', 'playable', 'seekable'): print '%-20s %s' % (key, getattr(result, key)) print 'tags' for tag, value in result.tags.items(): From 9bc4d8b713bcb22e65544e6bff860ab8432dca3a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 11 Apr 2015 00:29:07 +0200 Subject: [PATCH 049/318] audio: Make scanner handle all media types. I don't think this makes anything slower, as before we would still decode anything we came across in the hopes that we find raw audio. --- mopidy/audio/scan.py | 3 +-- tests/audio/test_scan.py | 29 +++++++++++++---------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 822277eb..9412b231 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -78,9 +78,8 @@ def _setup_pipeline(uri, proxy_config=None): if proxy_config: utils.setup_proxy(src, proxy_config) - decodebin.set_property('caps', _RAW_AUDIO) - decodebin.connect('pad-added', _pad_added, pipeline) typefind.connect('have-type', _have_type, decodebin) + decodebin.connect('pad-added', _pad_added, pipeline) return pipeline diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index b2937a3f..f58b2202 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -16,8 +16,7 @@ from tests import path_to_data_dir class ScannerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.errors = {} - self.tags = {} - self.durations = {} + self.result = {} def find(self, path): media_dir = path_to_data_dir(path) @@ -31,19 +30,17 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - result = scanner.scan(uri) - self.tags[key] = result.tags - self.durations[key] = result.duration + self.result[key] = scanner.scan(uri) except exceptions.ScannerError as error: self.errors[key] = error def check(self, name, key, value): name = path_to_data_dir(name) - self.assertEqual(self.tags[name][key], value) + self.assertEqual(self.result[name].tags[key], value) def test_tags_is_set(self): self.scan(self.find('scanner/simple')) - self.assert_(self.tags) + self.assert_(self.result.values()[0].tags) def test_errors_is_not_set(self): self.scan(self.find('scanner/simple')) @@ -52,10 +49,10 @@ class ScannerTest(unittest.TestCase): def test_duration_is_set(self): self.scan(self.find('scanner/simple')) - self.assertEqual( - self.durations[path_to_data_dir('scanner/simple/song1.mp3')], 4680) - self.assertEqual( - self.durations[path_to_data_dir('scanner/simple/song1.ogg')], 4680) + ogg = path_to_data_dir('scanner/simple/song1.ogg') + mp3 = path_to_data_dir('scanner/simple/song1.mp3') + self.assertEqual(self.result[mp3].duration, 4680) + self.assertEqual(self.result[ogg].duration, 4680) def test_artist_is_set(self): self.scan(self.find('scanner/simple')) @@ -78,17 +75,17 @@ class ScannerTest(unittest.TestCase): def test_other_media_is_ignored(self): self.scan(self.find('scanner/image')) - self.assert_(self.errors) + self.assertFalse(self.result.values()[0].playable) def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan([path_to_data_dir('scanner/example.log')]) - self.assertLess( - self.durations[path_to_data_dir('scanner/example.log')], 100) + log = path_to_data_dir('scanner/example.log') + self.assertLess(self.result[log].duration, 100) def test_empty_wav_file(self): self.scan([path_to_data_dir('scanner/empty.wav')]) - self.assertEqual( - self.durations[path_to_data_dir('scanner/empty.wav')], 0) + wav = path_to_data_dir('scanner/empty.wav') + self.assertEqual(self.result[wav].duration, 0) @unittest.SkipTest def test_song_without_time_is_handeled(self): From 48a461991a764b0bea368f7db3b3640ca80aa816 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 11 Apr 2015 00:38:46 +0200 Subject: [PATCH 050/318] local: Skip unplayable tracks --- mopidy/local/commands.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index af8b0025..4383decb 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -135,7 +135,9 @@ class ScanCommand(commands.Command): file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) result = scanner.scan(file_uri) tags, duration = result.tags, result.duration - if duration < MIN_DURATION_MS: + if not result.playable: + logger.warning('Failed %s: No audio found in file.', uri) + elif duration < MIN_DURATION_MS: logger.warning('Failed %s: Track shorter than %dms', uri, MIN_DURATION_MS) else: From 6327a678749b751033e4d885380e941eb7a18cce Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 11 Apr 2015 00:47:53 +0200 Subject: [PATCH 051/318] models: Make sure we really only add __weakref__ once --- mopidy/models.py | 8 +++++--- tests/models/test_models.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 4b268474..477b87a7 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import copy +import inspect import json import weakref @@ -145,11 +146,12 @@ class ImmutableObjectMeta(type): value._name = key attrs['_fields'] = fields - attrs['__slots__'] = fields.values() attrs['_instances'] = weakref.WeakValueDictionary() + attrs['__slots__'] = fields.values() - for base in bases: - if '__weakref__' in getattr(base, '__slots__', []): + anncestors = [b for base in bases for b in inspect.getmro(base)] + for anncestor in anncestors: + if '__weakref__' in getattr(anncestor, '__slots__', []): break else: attrs['__slots__'].append('__weakref__') diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 0407056c..27d02382 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -8,6 +8,17 @@ from mopidy.models import ( TlTrack, Track, model_json_decoder) +class InheritanecTest(unittest.TestCase): + + def test_weakref_and_slots_play_nice_in_subclass(self): + # Check that the following does not happen: + # TypeError: Error when calling the metaclass bases + # __weakref__ slot disallowed: either we already got one... + + class Foo(Track): + pass + + class CachingTest(unittest.TestCase): def test_same_instance(self): From 79d1862510331701308618001d8989e3b42f32af Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 11 Apr 2015 01:07:18 +0200 Subject: [PATCH 052/318] models: Compare stream of items for models __eq__ Creating dictionaries for this is was just wasteful. --- mopidy/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index 477b87a7..230c86cc 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import copy import inspect +import itertools import json import weakref @@ -225,7 +226,8 @@ class ImmutableObject(object): def __eq__(self, other): if not isinstance(other, self.__class__): return False - return dict(self._items()) == dict(other._items()) + return all(a == b for a, b in itertools.izip_longest( + self._items(), other._items(), fillvalue=object())) def __ne__(self, other): return not self.__eq__(other) From 777a663896fcb937f2cf6987794242081b6e4808 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 11 Apr 2015 01:10:00 +0200 Subject: [PATCH 053/318] models: Take advantage of fact that our hash won't change This might just be pointless micro-optimization as I have _not_ measured. But it seemed silly to recursively hash everything in a model each time a hash is required. As we know the data can not change. --- mopidy/models.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 230c86cc..03f991ab 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -148,7 +148,7 @@ class ImmutableObjectMeta(type): attrs['_fields'] = fields attrs['_instances'] = weakref.WeakValueDictionary() - attrs['__slots__'] = fields.values() + attrs['__slots__'] = ['_hash'] + fields.values() anncestors = [b for base in bases for b in inspect.getmro(base)] for anncestor in anncestors: @@ -218,10 +218,12 @@ class ImmutableObject(object): } def __hash__(self): - hash_sum = 0 - for key, value in self._items(): - hash_sum += hash(key) + hash(value) - return hash_sum + 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 def __eq__(self, other): if not isinstance(other, self.__class__): @@ -267,6 +269,7 @@ class ImmutableObject(object): raise TypeError( 'copy() got an unexpected keyword argument "%s"' % key) super(ImmutableObject, other).__setattr__(key, value) + super(ImmutableObject, other).__delattr__('_hash') return self._instances.setdefault(weakref.ref(other), other) def serialize(self): From 1a1a0753a47ade4faa01248136acf6062ded5365 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 12 Apr 2015 14:16:35 +0200 Subject: [PATCH 054/318] audio: Use print function in scanner --- mopidy/audio/scan.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 9412b231..f2a93620 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,4 +1,5 @@ -from __future__ import absolute_import, division, unicode_literals +from __future__ import ( + absolute_import, division, print_function, unicode_literals) import collections @@ -190,9 +191,9 @@ if __name__ == '__main__': try: result = scanner.scan(uri) for key in ('uri', 'mime', 'duration', 'playable', 'seekable'): - print '%-20s %s' % (key, getattr(result, key)) - print 'tags' + print('%-20s %s' % (key, getattr(result, key))) + print('tags') for tag, value in result.tags.items(): - print '%-20s %s' % (tag, value) + print('%-20s %s' % (tag, value)) except exceptions.ScannerError as error: - print '%s: %s' % (uri, error) + print('%s: %s' % (uri, error)) From 68c2758009b58ba81419b25baacb3ec6570cc174 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 12 Apr 2015 14:24:28 +0200 Subject: [PATCH 055/318] docs: Add scanner improvements to changelog --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ce7be87b..b3a7a459 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,10 @@ v1.0.1 (UNRELEASED) behavior was confusing for many users and doesn't work well with the plans for multiple outputs. +- Audio: Update scanner to decode all media it finds. This should fix cases + where the scanner hangs on non-audio files like video. The scanner will now + also let us know if we found any decodeable audio. (Fixes: :issue:`726`) + v1.0.0 (2015-03-25) =================== From 20019edf2d904f7b58924e8ae04cb6c9756dd827 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 12 Apr 2015 16:03:51 +0200 Subject: [PATCH 056/318] models: Fix review comments --- mopidy/models.py | 5 ++--- tests/models/test_models.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 03f991ab..f4404fb8 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -150,9 +150,8 @@ class ImmutableObjectMeta(type): attrs['_instances'] = weakref.WeakValueDictionary() attrs['__slots__'] = ['_hash'] + fields.values() - anncestors = [b for base in bases for b in inspect.getmro(base)] - for anncestor in anncestors: - if '__weakref__' in getattr(anncestor, '__slots__', []): + 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__') diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 27d02382..c9c91ba1 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -8,7 +8,7 @@ from mopidy.models import ( TlTrack, Track, model_json_decoder) -class InheritanecTest(unittest.TestCase): +class InheritanceTest(unittest.TestCase): def test_weakref_and_slots_play_nice_in_subclass(self): # Check that the following does not happen: From 71ab9733c760b68a49fa80f21793e1dbcdc3d86f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Apr 2015 23:03:46 +0200 Subject: [PATCH 057/318] flake8: Fix new import order warnings --- mopidy/audio/actor.py | 2 +- mopidy/audio/scan.py | 2 +- mopidy/utils/deps.py | 4 ++-- tests/audio/test_actor.py | 4 ++-- tests/stream/test_library.py | 4 ++-- tests/utils/test_deps.py | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index a1e1e119..674e68e1 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -8,7 +8,7 @@ import gobject import pygst pygst.require('0.10') import gst # noqa -import gst.pbutils +import gst.pbutils # noqa import pykka diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 384b4197..4f0cb584 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -5,7 +5,7 @@ import collections import pygst pygst.require('0.10') import gst # noqa -import gst.pbutils +import gst.pbutils # noqa from mopidy import exceptions from mopidy.audio import utils diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index bc9f7c2f..aafede9d 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -5,12 +5,12 @@ import os import platform import sys +import pkg_resources + import pygst pygst.require('0.10') import gst # noqa -import pkg_resources - from mopidy.utils import formatting diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 8cfb6a88..7d5f6148 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -6,12 +6,12 @@ import unittest import gobject gobject.threads_init() +import mock + import pygst pygst.require('0.10') import gst # noqa -import mock - import pykka from mopidy import audio diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index 462136e4..b2410bb7 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -5,12 +5,12 @@ import unittest import gobject gobject.threads_init() +import mock + import pygst pygst.require('0.10') import gst # noqa: pygst magic is needed to import correct gst -import mock - from mopidy.models import Track from mopidy.stream import actor from mopidy.utils.path import path_to_uri diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 0639d296..394fba85 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -6,12 +6,12 @@ import unittest import mock +import pkg_resources + import pygst pygst.require('0.10') import gst # noqa -import pkg_resources - from mopidy.utils import deps From f85ea2a39d9d87e5da491847a4bd3ad165fbd5ca Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Apr 2015 23:03:46 +0200 Subject: [PATCH 058/318] flake8: Fix new import order warnings (cherry picked from commit 71ab9733c760b68a49fa80f21793e1dbcdc3d86f) --- mopidy/audio/actor.py | 2 +- mopidy/audio/scan.py | 2 +- mopidy/utils/deps.py | 4 ++-- tests/audio/test_actor.py | 4 ++-- tests/stream/test_library.py | 4 ++-- tests/utils/test_deps.py | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 35bd215f..e0a7892a 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -8,7 +8,7 @@ import gobject import pygst pygst.require('0.10') import gst # noqa -import gst.pbutils +import gst.pbutils # noqa import pykka diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index f2a93620..d1e83407 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -6,7 +6,7 @@ import collections import pygst pygst.require('0.10') import gst # noqa -import gst.pbutils +import gst.pbutils # noqa from mopidy import exceptions from mopidy.audio import utils diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index bc9f7c2f..aafede9d 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -5,12 +5,12 @@ import os import platform import sys +import pkg_resources + import pygst pygst.require('0.10') import gst # noqa -import pkg_resources - from mopidy.utils import formatting diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index fbc440de..b00646bc 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -6,12 +6,12 @@ import unittest import gobject gobject.threads_init() +import mock + import pygst pygst.require('0.10') import gst # noqa -import mock - import pykka from mopidy import audio diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index 93292376..65c59cb6 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -5,12 +5,12 @@ import unittest import gobject gobject.threads_init() +import mock + import pygst pygst.require('0.10') import gst # noqa: pygst magic is needed to import correct gst -import mock - from mopidy.models import Track from mopidy.stream import actor from mopidy.utils.path import path_to_uri diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 95f5b982..3b06973f 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -6,12 +6,12 @@ import unittest import mock +import pkg_resources + import pygst pygst.require('0.10') import gst # noqa -import pkg_resources - from mopidy.utils import deps From 97515c8125436fc8b7694823baa4b9ffeb805e33 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 12 Apr 2015 23:59:20 +0200 Subject: [PATCH 059/318] mpd: Only short circuit 'add "uri"' case when we have a URI scheme --- mopidy/mpd/protocol/current_playlist.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 38ad4017..ea815c6a 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, unicode_literals +import urlparse + from mopidy.mpd import exceptions, protocol, translator from mopidy.utils import deprecation @@ -21,8 +23,11 @@ def add(context, uri): if not uri.strip('/'): return - if context.core.tracklist.add(uris=[uri]).get(): - return + # If we have an URI just try and add it directly without bothering with + # jumping through browse... + if urlparse.urlparse(uri).scheme != '': + if context.core.tracklist.add(uris=[uri]).get(): + return try: uris = [] From 8c7a9e3f958e80ca730c757796b76db97069460b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 13 Apr 2015 00:02:00 +0200 Subject: [PATCH 060/318] mpd: 'list "artist" ""' should not generate an invalid query --- mopidy/mpd/protocol/music_db.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index fc726255..541fcd6d 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -270,10 +270,12 @@ def list_(context, *args): if field not in _LIST_MAPPING: raise exceptions.MpdArgError('incorrect arguments') + query = None if len(params) == 1: if field != 'album': raise exceptions.MpdArgError('should be "Album" for 3 arguments') - query = {'artist': params} + if params[0].strip(): + query = {'artist': params} else: try: query = _query_from_mpd_search_parameters(params, _LIST_MAPPING) From 1b10a783d3830b381d59dfdb84c4e764460a4660 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 13 Apr 2015 00:16:09 +0200 Subject: [PATCH 061/318] mpd: Update tests to use setters and actual booleans --- tests/mpd/test_status.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 6f134df5..f6390e53 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -76,7 +76,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): - self.core.tracklist.repeat = 1 + self.core.tracklist.set_repeat(True) result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) @@ -87,7 +87,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): - self.core.tracklist.random = 1 + self.core.tracklist.set_random(True) result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 1) @@ -103,7 +103,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): - self.core.tracklist.consume = 1 + self.core.tracklist.set_consume(True) result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) From 94628b5f8232382a583d8c7bd991d0a46d9127a8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 13 Apr 2015 00:50:33 +0200 Subject: [PATCH 062/318] local: Don't use tuple form of TlTracks in tests --- tests/local/test_playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 2bda46d3..20601d93 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -10,7 +10,7 @@ import pykka from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor -from mopidy.models import Track +from mopidy.models import TlTrack, Track from mopidy.utils import deprecation from tests import dummy_audio, path_to_data_dir @@ -1088,4 +1088,4 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): with self.assertRaises(AssertionError): - self.playback.play((17, Track())) + self.playback.play(TlTrack(17, Track())) From c8b348a61deae2c8de2d504df1643dd90ec2de60 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 13 Apr 2015 08:16:54 +0200 Subject: [PATCH 063/318] docs: Tweak changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b3a7a459..6cf8ca15 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,8 @@ This changelog is used to track all major changes to Mopidy. v1.0.1 (UNRELEASED) =================== +Bug fix release. + - Audio: Software volume control has been reworked to greatly reduce the delay between changing the volume and the change taking effect. (Fixes: :issue:`1097`) From d545fd198e5966e12640ef27a8b975e29e58a51b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Apr 2015 21:55:26 +0200 Subject: [PATCH 064/318] http: Fix threading issue causing duplicate WS messages The problem presents itself when a JSON-RPC call triggers some event in core. When this happens we have a thread outside of Tornado call `write_message` interleaved with a potentially ongoing write if the JSON-RPC response. To avoid this we now follow Tornado best practices and add callbacks to the IOLoop to ensure that there isn't any interleaved access of Tornado state. --- docs/changelog.rst | 2 ++ mopidy/http/handlers.py | 29 +++++++++++++++++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6cf8ca15..ebc42c44 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -23,6 +23,8 @@ Bug fix release. where the scanner hangs on non-audio files like video. The scanner will now also let us know if we found any decodeable audio. (Fixes: :issue:`726`) +- HTTP: Fix threading bug that would cause duplicate delivery of WS messages. + v1.0.0 (2015-03-25) =================== diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index a5baf992..4f4b5988 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -5,6 +5,7 @@ import os import socket import tornado.escape +import tornado.ioloop import tornado.web import tornado.websocket @@ -65,6 +66,19 @@ def make_jsonrpc_wrapper(core_actor): ) +def _send_broadcast(client, msg): + # We could check for client.ws_connection, but we don't really + # care why the broadcast failed, we just want the rest of them + # to succeed, so catch everything. + try: + client.write_message(msg) + except Exception as e: + error_msg = encoding.locale_decode(e) + logger.debug('Broadcast of WebSocket message to %s failed: %s', + client.request.remote_ip, error_msg) + # TODO: should this do the same cleanup as the on_message code? + + class WebSocketHandler(tornado.websocket.WebSocketHandler): # XXX This set is shared by all WebSocketHandler objects. This isn't @@ -74,17 +88,12 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): @classmethod def broadcast(cls, msg): + # This can be called from outside the Tornado ioloop, so we need to + # safely cross the thread boundary by adding a callback to the loop. + loop = tornado.ioloop.IOLoop.current() for client in cls.clients: - # We could check for client.ws_connection, but we don't really - # care why the broadcast failed, we just want the rest of them - # to succeed, so catch everything. - try: - client.write_message(msg) - except Exception as e: - error_msg = encoding.locale_decode(e) - logger.debug('Broadcast of WebSocket message to %s failed: %s', - client.request.remote_ip, error_msg) - # TODO: should this do the same cleanup as the on_message code? + # One callback per client to keep time we hold up the loop short + loop.add_callback(_send_broadcast, client, msg) def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) From 8646ba42527dfea5c939147710bae351c84fa939 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Apr 2015 23:16:12 +0200 Subject: [PATCH 065/318] utils: Add validation helpers for verifying core APIs --- mopidy/exceptions.py | 4 + mopidy/utils/validation.py | 102 +++++++++++++++++++++ tests/utils/test_validation.py | 163 +++++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 mopidy/utils/validation.py create mode 100644 tests/utils/test_validation.py diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 32a2bd9a..d02a288a 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -46,3 +46,7 @@ class ScannerError(MopidyException): class AudioException(MopidyException): pass + + +class ValidationError(ValueError): + pass diff --git a/mopidy/utils/validation.py b/mopidy/utils/validation.py new file mode 100644 index 00000000..a0306564 --- /dev/null +++ b/mopidy/utils/validation.py @@ -0,0 +1,102 @@ +from __future__ import absolute_import, unicode_literals + +import collections +import urlparse + +from mopidy import compat, exceptions + +PLAYBACK_STATES = {'paused', 'stopped', 'playing'} + +QUERY_FIELDS = { + 'uri', 'track_name', 'album', 'artist', 'albumartist', 'composer', + 'performer', 'track_no', 'genre', 'date', 'comment', 'any', 'tlid', 'name'} + +DISTINCT_FIELDS = { + 'artist', 'albumartist', 'album', 'composer', 'performer', 'date', 'genre'} + + +# TODO: _check_iterable(check, msg, **kwargs) + [check(a) for a in arg]? +def _check_iterable(arg, msg, **kwargs): + """Ensure we have an iterable which is not a string.""" + if isinstance(arg, compat.string_types): + raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) + elif not isinstance(arg, collections.Iterable): + raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) + + +def check_choice(arg, choices, msg='Expected one of {choices}, not {arg!r}'): + if arg not in choices: + raise exceptions.ValidationError(msg.format( + arg=arg, choices=tuple(choices))) + + +def check_boolean(arg, msg='Expected a boolean, not {arg!r}'): + check_instance(arg, bool, msg=msg) + + +def check_instance(arg, cls, msg='Expected a {name} instance, not {arg!r}'): + if not isinstance(arg, cls): + raise exceptions.ValidationError( + msg.format(arg=arg, name=cls.__name__)) + + +def check_instances(arg, cls, msg='Expected a list of {name}, not {arg!r}'): + _check_iterable(arg, msg, name=cls.__name__) + if not all(isinstance(instance, cls) for instance in arg): + raise exceptions.ValidationError( + msg.format(arg=arg, name=cls.__name__)) + + +def check_integer(arg, min=None, max=None): + if not isinstance(arg, (int, long)): + raise exceptions.ValidationError('Expected an integer, not %r' % arg) + elif min is not None and arg < min: + raise exceptions.ValidationError( + 'Expected number larger or equal to %d, not %r' % (min, arg)) + elif max is not None and arg > max: + raise exceptions.ValidationError( + 'Expected number smaller or equal to %d, not %r' % (max, arg)) + + +def check_query(arg, list_values=True): + # TODO: normalize name -> track_name + # TODO: normalize value -> [value] + # TODO: normalize blank -> [] or just remove field? + # TODO: normalize int -> str or remove int support? + # TODO: remove list_values? + # TODO: don't allow for instance tlid field in all queries? + + if not isinstance(arg, collections.Mapping): + raise exceptions.ValidationError( + 'Expected a query dictionary, not {arg!r}'.format(arg=arg)) + + for key, value in arg.items(): + check_choice(key, QUERY_FIELDS, msg='Expected query field to be one ' + 'of {choices}, not {arg!r}') + if list_values: + msg = 'Expected "{key}" values to be list of strings, not {arg!r}' + _check_iterable(value, msg, key=key) + [_check_query_value(key, v, msg) for v in value] + else: + _check_query_value(key, value, 'Expected "{key}" value to be a ' + 'string, not {arg!r}') + + +def _check_query_value(key, arg, msg): + if isinstance(arg, compat.string_types): + if not arg.strip(): + raise exceptions.ValidationError(msg.format(arg=arg, key=key)) + elif not isinstance(arg, (int, long)): + raise exceptions.ValidationError(msg.format(arg=arg, key=key)) + + +def check_uri(arg, msg='Expected a valid URI, not {arg!r}'): + if not isinstance(arg, compat.string_types): + raise exceptions.ValidationError(msg.format(arg=arg)) + elif urlparse.urlparse(arg).scheme == '': + raise exceptions.ValidationError(msg.format(arg=arg)) + + +def check_uris(arg, msg='Expected a list of URIs, not {arg!r}'): + _check_iterable(arg, msg) + [check_uri(a, msg) for a in arg] diff --git a/tests/utils/test_validation.py b/tests/utils/test_validation.py new file mode 100644 index 00000000..d55a918e --- /dev/null +++ b/tests/utils/test_validation.py @@ -0,0 +1,163 @@ +from __future__ import absolute_import, unicode_literals + +from pytest import raises + +from mopidy import compat, exceptions +from mopidy.utils import validation + + +def test_check_boolean_with_valid_values(): + for value in (True, False): + validation.check_boolean(value) + + +def test_check_boolean_with_truthy_values(): + for value in 1, 0, None, '', list(), tuple(): + with raises(exceptions.ValidationError): + validation.check_boolean(value) + + +def test_check_boolean_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_boolean(1234) + assert 'Expected a boolean, not 1234' == str(excinfo.value) + + +def test_check_choice_with_valid_values(): + for value, choices in (2, (1, 2, 3)), ('abc', ('abc', 'def')): + validation.check_choice(value, choices) + + +def test_check_choice_with_invalid_values(): + for value, choices in (5, (1, 2, 3)), ('xyz', ('abc', 'def')): + with raises(exceptions.ValidationError): + validation.check_choice(value, choices) + + +def test_check_choice_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_choice(5, (1, 2, 3)) + assert 'Expected one of (1, 2, 3), not 5' == str(excinfo.value) + + +def test_check_instance_with_valid_choices(): + for value, cls in ((True, bool), ('a', compat.text_type), (123, int)): + validation.check_instance(value, cls) + + +def test_check_instance_with_invalid_values(): + for value, cls in (1, str), ('abc', int): + with raises(exceptions.ValidationError): + validation.check_instance(value, cls) + + +def test_check_instance_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_instance(1, dict) + assert 'Expected a dict instance, not 1' == str(excinfo.value) + + +def test_check_instances_with_valid_values(): + validation.check_instances([], int) + validation.check_instances([1, 2], int) + validation.check_instances((1, 2), int) + + +def test_check_instances_with_invalid_values(): + with raises(exceptions.ValidationError): + validation.check_instances('abc', compat.string_types) + with raises(exceptions.ValidationError): + validation.check_instances(['abc', 123], compat.string_types) + with raises(exceptions.ValidationError): + validation.check_instances(None, compat.string_types) + with raises(exceptions.ValidationError): + validation.check_instances([None], compat.string_types) + + +def test_check_instances_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_instances([1], compat.string_types) + assert 'Expected a list of basestring, not [1]' == str(excinfo.value) + + +def test_check_query_valid_values(): + for value in {}, {'any': []}, {'any': ['abc']}: + validation.check_query(value) + + +def test_check_query_random_iterables(): + for value in None, tuple(), list(), 'abc': + with raises(exceptions.ValidationError): + validation.check_query(value) + + +def test_check_mapping_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_query([]) + assert 'Expected a query dictionary, not []' == str(excinfo.value) + + +def test_check_query_invalid_fields(): + for value in 'wrong', 'bar', 'foo': + with raises(exceptions.ValidationError): + validation.check_query({value: []}) + + +def test_check_field_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_query({'wrong': ['abc']}) + assert 'Expected query field to be one of ' in str(excinfo.value) + + +def test_check_query_invalid_values(): + for value in '', None, 'foo', 123, [''], [None]: + with raises(exceptions.ValidationError): + validation.check_query({'any': value}) + + +def test_check_values_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_query({'any': 'abc'}) + assert 'Expected "any" values to be list of strings' in str(excinfo.value) + + +def test_check_uri_with_valid_values(): + for value in 'foobar:', 'http://example.com', 'git+http://example.com': + validation.check_uri(value) + + +def test_check_uri_with_invalid_values(): + # Note that tuple catches a potential bug with using "'foo' % arg" for + # formatting. + for value in ('foobar', 'htt p://example.com', None, 1234, tuple()): + with raises(exceptions.ValidationError): + validation.check_uri(value) + + +def test_check_uri_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_uri('testing') + assert "Expected a valid URI, not u'testing'" == str(excinfo.value) + + +def test_check_uris_with_valid_values(): + validation.check_uris([]) + validation.check_uris(['foobar:']) + validation.check_uris(('foobar:',)) + + +def test_check_uris_with_invalid_values(): + with raises(exceptions.ValidationError): + validation.check_uris('foobar:') + with raises(exceptions.ValidationError): + validation.check_uris(None) + with raises(exceptions.ValidationError): + validation.check_uris([None]) + with raises(exceptions.ValidationError): + validation.check_uris(['foobar:', 'foobar']) + + +def test_check_uris_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_uris('testing') + assert "Expected a list of URIs, not u'testing'" == str(excinfo.value) From 324bec1f4a28e5f808e42a9238549a3f8e2f2aea Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Apr 2015 23:20:06 +0200 Subject: [PATCH 066/318] core: Validate core API calls --- mopidy/core/library.py | 23 ++++++++++++-- mopidy/core/mixer.py | 5 +++ mopidy/core/playback.py | 8 ++++- mopidy/core/playlists.py | 16 ++++++++-- mopidy/core/tracklist.py | 37 ++++++++++++++-------- mopidy/utils/validation.py | 6 ++-- tests/local/test_library.py | 56 ++++++++++++++++++---------------- tests/local/test_playback.py | 16 ---------- tests/utils/test_validation.py | 2 +- 9 files changed, 105 insertions(+), 64 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 6fc1ce38..5971ec6e 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -5,8 +5,7 @@ import logging import operator import urlparse - -from mopidy.utils import deprecation +from mopidy.utils import deprecation, validation logger = logging.getLogger(__name__) @@ -70,6 +69,9 @@ class LibraryController(object): """ if uri is None: return self._roots() + elif not uri.strip(): + return [] + validation.check_uri(uri) return self._browse(uri) def _roots(self): @@ -111,6 +113,9 @@ class LibraryController(object): .. versionadded:: 1.0 """ + validation.check_choice(field, validation.DISTINCT_FIELDS) + query is None or validation.check_query(query) # TODO: normalize? + result = set() futures = {b: b.library.get_distinct(field, query) for b in self.backends.with_library.values()} @@ -137,6 +142,8 @@ class LibraryController(object): .. versionadded:: 1.0 """ + validation.check_uris(uris) + futures = { backend: backend.library.get_images(backend_uris) for (backend, backend_uris) @@ -187,6 +194,11 @@ class LibraryController(object): if none_set or both_set: raise ValueError("One of 'uri' or 'uris' must be set") + # TODO: validation.one_of(*args)? + + uris is None or validation.check_uris(uris) + uri is None or validation.check_uri(uri) + if uri: deprecation.warn('core.library.lookup:uri_arg') @@ -219,6 +231,8 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ + uri is None or validation.check_uri(uri) + futures = {} backends = {} uri_scheme = urlparse.urlparse(uri).scheme if uri else None @@ -285,6 +299,10 @@ class LibraryController(object): """ query = _normalize_query(query or kwargs) + uris is None or validation.check_uris(uris) + query is None or validation.check_query(query) + validation.check_boolean(exact) + if kwargs: deprecation.warn('core.library.search:kwargs_query') @@ -319,6 +337,7 @@ class LibraryController(object): def _normalize_query(query): broken_client = False + # TODO: this breaks if query is not a dictionary like object... for (field, values) in query.items(): if isinstance(values, basestring): broken_client = True diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 3388d706..fde7ee5a 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, unicode_literals import logging +from mopidy.utils import validation + logger = logging.getLogger(__name__) @@ -31,6 +33,8 @@ class MixerController(object): Returns :class:`True` if call is successful, otherwise :class:`False`. """ + validation.check_integer(volume, min=0, max=100) + if self._mixer is None: return False else: @@ -52,6 +56,7 @@ class MixerController(object): Returns :class:`True` if call is successful, otherwise :class:`False`. """ + validation.check_boolean(mute) if self._mixer is None: return False else: diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index abf9ae8a..135e1828 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -3,9 +3,10 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse +from mopidy import models from mopidy.audio import PlaybackState from mopidy.core import listener -from mopidy.utils import deprecation +from mopidy.utils import deprecation, validation logger = logging.getLogger(__name__) @@ -96,6 +97,8 @@ class PlaybackController(object): "PAUSED" -> "PLAYING" [ label="resume" ] "PAUSED" -> "STOPPED" [ label="stop" ] """ + validation.check_choice(new_state, validation.PLAYBACK_STATES) + (old_state, self._state) = (self.get_state(), new_state) logger.debug('Changing state: %s -> %s', old_state, new_state) @@ -270,6 +273,7 @@ class PlaybackController(object): :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` """ + tl_track is None or validation.check_instance(tl_track, models.TlTrack) self._play(tl_track, on_error_step=1) def _play(self, tl_track=None, on_error_step=1): @@ -360,6 +364,8 @@ class PlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ + validation.check_integer(time_position, min=0) + if not self.core.tracklist.tracks: return False diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 62001517..a430e3ff 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -5,7 +5,7 @@ import urlparse from mopidy.core import listener from mopidy.models import Playlist -from mopidy.utils import deprecation +from mopidy.utils import deprecation, validation logger = logging.getLogger(__name__) @@ -62,6 +62,8 @@ class PlaylistsController(object): .. versionadded:: 1.0 """ + validation.check_uri(uri) + uri_scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) if backend: @@ -139,6 +141,8 @@ class PlaylistsController(object): :param uri: URI of the playlist to delete :type uri: string """ + validation.check_uri(uri) + uri_scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) if backend: @@ -172,6 +176,9 @@ class PlaylistsController(object): deprecation.warn('core.playlists.filter') criteria = criteria or kwargs + validation.check_query(criteria, list_values=False) + + # TODO: stop using self playlists matches = self.playlists for (key, value) in criteria.iteritems(): matches = filter(lambda p: getattr(p, key) == value, matches) @@ -207,6 +214,8 @@ class PlaylistsController(object): :param uri_scheme: limit to the backend matching the URI scheme :type uri_scheme: string """ + # TODO: check: uri_scheme is None or uri_scheme? + futures = {} backends = {} playlists_loaded = False @@ -251,8 +260,11 @@ class PlaylistsController(object): :type playlist: :class:`mopidy.models.Playlist` :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ + validation.check_instance(playlist, Playlist) + if playlist.uri is None: - return + return # TODO: log this problem? + uri_scheme = urlparse.urlparse(playlist.uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) if backend: diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 9a251b75..359efa0c 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -1,13 +1,11 @@ from __future__ import absolute_import, unicode_literals -import collections import logging import random -from mopidy import compat from mopidy.core import listener -from mopidy.models import TlTrack -from mopidy.utils import deprecation +from mopidy.models import TlTrack, Track +from mopidy.utils import deprecation, validation logger = logging.getLogger(__name__) @@ -93,6 +91,7 @@ class TracklistController(object): :class:`False` Tracks are not removed from the tracklist. """ + validation.check_boolean(value) if self.get_consume() != value: self._trigger_options_changed() return setattr(self, '_consume', value) @@ -121,7 +120,7 @@ class TracklistController(object): :class:`False` Tracks are played in the order of the tracklist. """ - + validation.check_boolean(value) if self.get_random() != value: self._trigger_options_changed() if value: @@ -157,7 +156,7 @@ class TracklistController(object): :class:`False` The tracklist is played once. """ - + validation.check_boolean(value) if self.get_repeat() != value: self._trigger_options_changed() return setattr(self, '_repeat', value) @@ -188,6 +187,7 @@ class TracklistController(object): :class:`False` Playback continues after current song. """ + validation.check_boolean(value) if self.get_single() != value: self._trigger_options_changed() return setattr(self, '_single', value) @@ -205,9 +205,10 @@ class TracklistController(object): The position of the given track in the tracklist. :param tl_track: the track to find the index of - :type tl_track: :class:`mopidy.models.TlTrack` + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`int` or :class:`None` """ + tl_track is None or validation.check_instance(tl_track, TlTrack) try: return self._tl_tracks.index(tl_track) except ValueError: @@ -223,6 +224,7 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + tl_track is None or validation.check_instance(tl_track, TlTrack) if self.get_single() and self.get_repeat(): return tl_track elif self.get_single(): @@ -247,6 +249,7 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + tl_track is None or validation.check_instance(tl_track, TlTrack) if not self.get_tl_tracks(): return None @@ -288,6 +291,8 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + tl_track is None or validation.check_instance(tl_track, TlTrack) + if self.get_repeat() or self.get_consume() or self.get_random(): return tl_track @@ -330,8 +335,13 @@ class TracklistController(object): """ assert tracks is not None or uri is not None or uris is not None, \ 'tracks, uri or uris must be provided' + # TODO: check that only one of tracks uri and uris is set... + # TODO: can at_position be negative? - # TODO: assert that tracks are track instances + tracks is None or validation.check_instances(tracks, Track) + uri is None or validation.check_uri(uri) + uris is None or validation.check_uris(uris) + validation.check_integer(at_position or 0) if tracks: deprecation.warn('core.tracklist.add:tracks_arg') @@ -412,12 +422,11 @@ class TracklistController(object): :rtype: list of :class:`mopidy.models.TlTrack` """ criteria = criteria or kwargs + validation.check_query(criteria) + # TODO: deprecate kwargs + matches = self._tl_tracks for (key, values) in criteria.items(): - if (not isinstance(values, collections.Iterable) or - isinstance(values, compat.string_types)): - # Fail hard if anyone is using the <0.17 calling style - raise ValueError('Filter values must be iterable: %r' % values) if key == 'tlid': matches = [ct for ct in matches if ct.tlid in values] else: @@ -443,6 +452,7 @@ class TracklistController(object): tl_tracks = self._tl_tracks + # TODO: use validation helpers? assert start < end, 'start must be smaller than end' assert start >= 0, 'start must be at least zero' assert end <= len(tl_tracks), \ @@ -470,6 +480,7 @@ class TracklistController(object): :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` that was removed """ + # TODO: deprecate kwargs tl_tracks = self.filter(criteria, **kwargs) for tl_track in tl_tracks: position = self._tl_tracks.index(tl_track) @@ -491,6 +502,7 @@ class TracklistController(object): """ tl_tracks = self._tl_tracks + # TOOD: use validation helpers? if start is not None and end is not None: assert start < end, 'start must be smaller than end' @@ -519,6 +531,7 @@ class TracklistController(object): :type end: int :rtype: :class:`mopidy.models.TlTrack` """ + # TODO: validate slice? return self._tl_tracks[start:end] def _mark_playing(self, tl_track): diff --git a/mopidy/utils/validation.py b/mopidy/utils/validation.py index a0306564..25e9f227 100644 --- a/mopidy/utils/validation.py +++ b/mopidy/utils/validation.py @@ -74,12 +74,12 @@ def check_query(arg, list_values=True): check_choice(key, QUERY_FIELDS, msg='Expected query field to be one ' 'of {choices}, not {arg!r}') if list_values: - msg = 'Expected "{key}" values to be list of strings, not {arg!r}' + msg = 'Expected "{key}" to be list of strings, not {arg!r}' _check_iterable(value, msg, key=key) [_check_query_value(key, v, msg) for v in value] else: - _check_query_value(key, value, 'Expected "{key}" value to be a ' - 'string, not {arg!r}') + _check_query_value( + key, value, 'Expected "{key}" to be a string, not {arg!r}') def _check_query_value(key, arg, msg): diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 0198ec9e..7763057f 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -9,7 +9,7 @@ import mock import pykka -from mopidy import core +from mopidy import core, exceptions from mopidy.local import actor, json from mopidy.models import Album, Artist, Image, Track @@ -137,8 +137,8 @@ class LocalLibraryProviderTest(unittest.TestCase): self.assertEqual(result[uri], self.tracks[0:1]) def test_lookup_unknown_track(self): - tracks = self.library.lookup(uris=['fake uri']) - self.assertEqual(tracks, {'fake uri': []}) + tracks = self.library.lookup(uris=['fake:/uri']) + self.assertEqual(tracks, {'fake:/uri': []}) # test backward compatibility with local libraries returning a # single Track @@ -343,42 +343,44 @@ class LocalLibraryProviderTest(unittest.TestCase): result = self.find_exact(any=['local:track:path1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) + # TODO: This is really just a test of the query validation code now, + # as this code path never even makes it to the local backend. def test_find_exact_wrong_type(self): - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(wrong=['test']) def test_find_exact_with_empty_query(self): - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(artist=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(albumartist=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(track_name=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(composer=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(performer=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(album=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(track_no=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(genre=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(date=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(comment=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(any=['']) def test_search_no_hits(self): @@ -553,41 +555,41 @@ class LocalLibraryProviderTest(unittest.TestCase): self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(wrong=['test']) def test_search_with_empty_query(self): - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(artist=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(albumartist=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(composer=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(performer=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(track_name=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(album=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(genre=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(date=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(comment=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(uri=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(any=['']) def test_default_get_images_impl_no_images(self): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 20601d93..8aedcfbc 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -841,22 +841,6 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.seek(self.tracklist.tracks[-1].length * 100) self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @unittest.SkipTest - @populate_tracklist - def test_seek_beyond_start_of_song(self): - # FIXME need to decide return value - self.playback.play() - result = self.playback.seek(-1000) - self.assert_(not result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_beyond_start_of_song_update_postion(self): - self.playback.play() - self.playback.seek(-1000) - position = self.playback.time_position - self.assertGreaterEqual(position, 0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_tracklist def test_stop_when_stopped(self): self.playback.stop() diff --git a/tests/utils/test_validation.py b/tests/utils/test_validation.py index d55a918e..fdebd4d2 100644 --- a/tests/utils/test_validation.py +++ b/tests/utils/test_validation.py @@ -118,7 +118,7 @@ def test_check_query_invalid_values(): def test_check_values_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_query({'any': 'abc'}) - assert 'Expected "any" values to be list of strings' in str(excinfo.value) + assert 'Expected "any" to be list of strings, not' in str(excinfo.value) def test_check_uri_with_valid_values(): From 97235f9441bea8bc94a6d41c8b96eb4f230b4524 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Apr 2015 23:46:20 +0200 Subject: [PATCH 067/318] core: Don't allow TLIDs in queries, or integers Handle this in tracklist.filter() which is the only API that allows number and/or TLIDs. --- mopidy/core/tracklist.py | 11 ++++++----- mopidy/utils/validation.py | 9 ++------- tests/utils/test_validation.py | 2 +- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 359efa0c..54143578 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -422,16 +422,17 @@ class TracklistController(object): :rtype: list of :class:`mopidy.models.TlTrack` """ criteria = criteria or kwargs + tlids = criteria.pop('tlid', []) validation.check_query(criteria) + validation.check_instances(tlids, int) # TODO: deprecate kwargs matches = self._tl_tracks for (key, values) in criteria.items(): - if key == 'tlid': - matches = [ct for ct in matches if ct.tlid in values] - else: - matches = [ - ct for ct in matches if getattr(ct.track, key) in values] + matches = [ + ct for ct in matches if getattr(ct.track, key) in values] + if tlids: + matches = [ct for ct in matches if ct.tlid in tlids] return matches def move(self, start, end, to_position): diff --git a/mopidy/utils/validation.py b/mopidy/utils/validation.py index 25e9f227..c158f340 100644 --- a/mopidy/utils/validation.py +++ b/mopidy/utils/validation.py @@ -9,7 +9,7 @@ PLAYBACK_STATES = {'paused', 'stopped', 'playing'} QUERY_FIELDS = { 'uri', 'track_name', 'album', 'artist', 'albumartist', 'composer', - 'performer', 'track_no', 'genre', 'date', 'comment', 'any', 'tlid', 'name'} + 'performer', 'track_no', 'genre', 'date', 'comment', 'any', 'name'} DISTINCT_FIELDS = { 'artist', 'albumartist', 'album', 'composer', 'performer', 'date', 'genre'} @@ -62,9 +62,7 @@ def check_query(arg, list_values=True): # TODO: normalize name -> track_name # TODO: normalize value -> [value] # TODO: normalize blank -> [] or just remove field? - # TODO: normalize int -> str or remove int support? # TODO: remove list_values? - # TODO: don't allow for instance tlid field in all queries? if not isinstance(arg, collections.Mapping): raise exceptions.ValidationError( @@ -83,10 +81,7 @@ def check_query(arg, list_values=True): def _check_query_value(key, arg, msg): - if isinstance(arg, compat.string_types): - if not arg.strip(): - raise exceptions.ValidationError(msg.format(arg=arg, key=key)) - elif not isinstance(arg, (int, long)): + if not isinstance(arg, compat.string_types) or not arg.strip(): raise exceptions.ValidationError(msg.format(arg=arg, key=key)) diff --git a/tests/utils/test_validation.py b/tests/utils/test_validation.py index fdebd4d2..d9413686 100644 --- a/tests/utils/test_validation.py +++ b/tests/utils/test_validation.py @@ -98,7 +98,7 @@ def test_check_mapping_error_message(): def test_check_query_invalid_fields(): - for value in 'wrong', 'bar', 'foo': + for value in 'wrong', 'bar', 'foo', 'tlid': with raises(exceptions.ValidationError): validation.check_query({value: []}) From 2c31dbe47c1f24b9ed061a7a2f2c7beb21b3dc6a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Apr 2015 23:42:45 +0200 Subject: [PATCH 068/318] core: Check correct query fields in core --- mopidy/core/playlists.py | 3 ++- mopidy/core/tracklist.py | 3 ++- mopidy/utils/validation.py | 15 ++++++++++----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index a430e3ff..b470fa56 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -176,7 +176,8 @@ class PlaylistsController(object): deprecation.warn('core.playlists.filter') criteria = criteria or kwargs - validation.check_query(criteria, list_values=False) + validation.check_query( + criteria, validation.PLAYLIST_FIELDS, list_values=False) # TODO: stop using self playlists matches = self.playlists diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 54143578..21c6d86a 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -423,9 +423,10 @@ class TracklistController(object): """ criteria = criteria or kwargs tlids = criteria.pop('tlid', []) - validation.check_query(criteria) + validation.check_query(criteria, validation.TRACKLIST_FIELDS) validation.check_instances(tlids, int) # TODO: deprecate kwargs + # TODO: id=[1, 2, 3] filtering can't possibly be working matches = self._tl_tracks for (key, values) in criteria.items(): diff --git a/mopidy/utils/validation.py b/mopidy/utils/validation.py index c158f340..4897f513 100644 --- a/mopidy/utils/validation.py +++ b/mopidy/utils/validation.py @@ -7,9 +7,14 @@ from mopidy import compat, exceptions PLAYBACK_STATES = {'paused', 'stopped', 'playing'} -QUERY_FIELDS = { +SEARCH_FIELDS = { 'uri', 'track_name', 'album', 'artist', 'albumartist', 'composer', - 'performer', 'track_no', 'genre', 'date', 'comment', 'any', 'name'} + 'performer', 'track_no', 'genre', 'date', 'comment', 'any'} + +PLAYLIST_FIELDS = {'uri', 'name'} # TODO: add length and last_modified? + +TRACKLIST_FIELDS = { # TODO: add bitrate, length, disc_no, track_no, modified? + 'uri', 'name', 'genre', 'date', 'comment', 'musicbrainz_id'} DISTINCT_FIELDS = { 'artist', 'albumartist', 'album', 'composer', 'performer', 'date', 'genre'} @@ -58,7 +63,7 @@ def check_integer(arg, min=None, max=None): 'Expected number smaller or equal to %d, not %r' % (max, arg)) -def check_query(arg, list_values=True): +def check_query(arg, fields=SEARCH_FIELDS, list_values=True): # TODO: normalize name -> track_name # TODO: normalize value -> [value] # TODO: normalize blank -> [] or just remove field? @@ -69,8 +74,8 @@ def check_query(arg, list_values=True): 'Expected a query dictionary, not {arg!r}'.format(arg=arg)) for key, value in arg.items(): - check_choice(key, QUERY_FIELDS, msg='Expected query field to be one ' - 'of {choices}, not {arg!r}') + check_choice(key, fields, msg='Expected query field to be one of ' + '{choices}, not {arg!r}') if list_values: msg = 'Expected "{key}" to be list of strings, not {arg!r}' _check_iterable(value, msg, key=key) From 98587f50986fa32a87994fa366904369608dd9ff Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Apr 2015 23:48:44 +0200 Subject: [PATCH 069/318] review: Fix test name --- tests/utils/test_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/test_validation.py b/tests/utils/test_validation.py index d9413686..67b42a4c 100644 --- a/tests/utils/test_validation.py +++ b/tests/utils/test_validation.py @@ -11,7 +11,7 @@ def test_check_boolean_with_valid_values(): validation.check_boolean(value) -def test_check_boolean_with_truthy_values(): +def test_check_boolean_with_other_values(): for value in 1, 0, None, '', list(), tuple(): with raises(exceptions.ValidationError): validation.check_boolean(value) From 0b928e787672c6f86c5449566e1e4ecae7ae9dd8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Apr 2015 23:51:33 +0200 Subject: [PATCH 070/318] docs: Add core input validation to changelog --- docs/changelog.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a730af21..5ea9ef09 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,9 +13,11 @@ Core API - Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs`` as the query is no longer supported (PR: :issue:`1090`) -- Update core controllers to handle backend exceptions in all calls that rely +- Updated core controllers to handle backend exceptions in all calls that rely on multiple backends. (Issue: :issue:`667`) +- Update core methods to do strict input checking. (Fixes: :issue:`#700`) + Models ------ From 282843200808b52523ac3bcd31d8e3beeb819b9c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 16 Apr 2015 00:23:28 +0200 Subject: [PATCH 071/318] core: Deprecate remaining methods that used kwargs --- docs/changelog.rst | 8 ++++++-- mopidy/core/library.py | 4 ---- mopidy/core/playlists.py | 6 +----- mopidy/core/tracklist.py | 21 +++++++++++++-------- mopidy/utils/deprecation.py | 4 ++++ 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 95a3156b..0011d60b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,8 +10,12 @@ v1.1.0 (UNRELEASED) Core API -------- -- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs`` - as the query is no longer supported (PR: :issue:`1090`) +- 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`` - Updated core controllers to handle backend exceptions in all calls that rely on multiple backends. (Issue: :issue:`667`) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 5971ec6e..7140f2cd 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -263,21 +263,17 @@ class LibraryController(object): # Returns results matching 'a' in any backend search({'any': ['a']}) - search(any=['a']) # Returns results matching artist 'xyz' in any backend search({'artist': ['xyz']}) - search(artist=['xyz']) # Returns results matching 'a' and 'b' and artist 'xyz' in any # backend search({'any': ['a', 'b'], 'artist': ['xyz']}) - search(any=['a', 'b'], artist=['xyz']) # Returns results matching 'a' if within the given URI roots # "file:///media/music" and "spotify:" search({'any': ['a']}, uris=['file:///media/music', 'spotify:']) - search(any=['a'], uris=['file:///media/music', 'spotify:']) :param query: one or more queries to search for :type query: dict diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index b470fa56..9be5efa7 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -156,15 +156,12 @@ class PlaylistsController(object): # Returns track with name 'a' filter({'name': 'a'}) - filter(name='a') # Returns track with URI 'xyz' filter({'uri': 'xyz'}) - filter(uri='xyz') # Returns track with name 'a' and URI 'xyz' filter({'name': 'a', 'uri': 'xyz'}) - filter(name='a', uri='xyz') :param criteria: one or more criteria to match by :type criteria: dict @@ -179,8 +176,7 @@ class PlaylistsController(object): validation.check_query( criteria, validation.PLAYLIST_FIELDS, list_values=False) - # TODO: stop using self playlists - matches = self.playlists + matches = self.playlists # TODO: stop using self playlists for (key, value) in criteria.iteritems(): matches = filter(lambda p: getattr(p, key) == value, matches) return matches diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 21c6d86a..596f759f 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -398,34 +398,34 @@ class TracklistController(object): # Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID) filter({'tlid': [1, 2, 3, 4]}) - filter(tlid=[1, 2, 3, 4]) # Returns track with IDs 1, 5, or 7 filter({'id': [1, 5, 7]}) - filter(id=[1, 5, 7]) # Returns track with URIs 'xyz' or 'abc' filter({'uri': ['xyz', 'abc']}) - filter(uri=['xyz', 'abc']) # Returns tracks with ID 1 and URI 'xyz' filter({'id': [1], 'uri': ['xyz']}) - filter(id=[1], uri=['xyz']) # Returns track with a matching ID (1, 3 or 6) and a matching URI # ('xyz' or 'abc') filter({'id': [1, 3, 6], 'uri': ['xyz', 'abc']}) - filter(id=[1, 3, 6], uri=['xyz', 'abc']) :param criteria: on or more criteria to match by :type criteria: dict, of (string, list) pairs :rtype: list of :class:`mopidy.models.TlTrack` + + .. deprecated:: 1.1 + Providing the criteria via ``kwargs`` is no longer supported. """ + if kwargs: + deprecation.warn('core.tracklist.filter:kwargs_criteria') + criteria = criteria or kwargs tlids = criteria.pop('tlid', []) validation.check_query(criteria, validation.TRACKLIST_FIELDS) validation.check_instances(tlids, int) - # TODO: deprecate kwargs # TODO: id=[1, 2, 3] filtering can't possibly be working matches = self._tl_tracks @@ -481,9 +481,14 @@ class TracklistController(object): :param criteria: on or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` that was removed + + .. deprecated:: 1.1 + Providing the criteria via ``kwargs`` is no longer supported. """ - # TODO: deprecate kwargs - tl_tracks = self.filter(criteria, **kwargs) + if kwargs: + deprecation.warn('core.tracklist.remove:kwargs_criteria') + + tl_tracks = self.filter(criteria or kwargs) for tl_track in tl_tracks: position = self._tl_tracks.index(tl_track) del self._tl_tracks[position] diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 4f26b41b..db263e6d 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -40,6 +40,10 @@ _MESSAGES = { 'tracklist.add() "tracks" argument is deprecated', 'core.tracklist.add:uri_arg': 'tracklist.add() "uri" argument is deprecated', + 'core.tracklist.filter:kwargs_criteria': + 'tracklist.filter() with "kwargs" as criteria is deprecated', + 'core.tracklist.remove:kwargs_criteria': + 'tracklist.remove() with "kwargs" as criteria is deprecated', 'models.immutable.copy': 'ImmutableObject.copy() is deprecated, use ImmutableObject.replace()', From efad50c253dbf4c53feb4fe81addcdea8d392e0d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 16 Apr 2015 00:25:01 +0200 Subject: [PATCH 072/318] cleanup: Stop using deprecated copy() --- mopidy/core/playlists.py | 2 +- mopidy/local/commands.py | 2 +- mopidy/m3u/playlists.py | 2 +- mopidy/m3u/translator.py | 6 +-- mopidy/mpd/protocol/music_db.py | 2 +- mopidy/stream/actor.py | 2 +- tests/audio/test_utils.py | 80 ++++++++++++++--------------- tests/core/test_events.py | 2 +- tests/m3u/test_playlists.py | 18 +++---- tests/m3u/test_translator.py | 6 +-- tests/mpd/protocol/test_music_db.py | 2 +- tests/mpd/test_translator.py | 16 +++--- 12 files changed, 70 insertions(+), 70 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 9be5efa7..aa5befaf 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -92,7 +92,7 @@ class PlaylistsController(object): # Use the playlist name from as_list() because it knows about any # playlist folder hierarchy, which lookup() does not. return [ - playlists[r.uri].copy(name=r.name) + playlists[r.uri].replace(name=r.name) for r in playlist_refs if playlists[r.uri] is not None] else: return [ diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 486e205f..c8c70216 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -143,7 +143,7 @@ class ScanCommand(commands.Command): uri, MIN_DURATION_MS) else: mtime = file_mtimes.get(os.path.join(media_dir, relpath)) - track = utils.convert_tags_to_track(tags).copy( + track = utils.convert_tags_to_track(tags).replace( uri=uri, length=duration, last_modified=mtime) if library.add_supports_tags_and_duration: library.add(track, tags=tags, duration=duration) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index eaa3d980..bfb27dc0 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -126,4 +126,4 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): file_handle.write(track.uri + '\n') # assert playlist name matches file name/uri - return playlist.copy(uri=uri, name=name) + return playlist.replace(uri=uri, name=name) diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py index 4eefce9d..177ab6c3 100644 --- a/mopidy/m3u/translator.py +++ b/mopidy/m3u/translator.py @@ -98,13 +98,13 @@ def parse_m3u(file_path, media_dir=None): continue if urlparse.urlsplit(line).scheme: - tracks.append(track.copy(uri=line)) + tracks.append(track.replace(uri=line)) elif os.path.normpath(line) == os.path.abspath(line): path = path_to_uri(line) - tracks.append(track.copy(uri=path)) + tracks.append(track.replace(uri=path)) elif media_dir is not None: path = path_to_uri(os.path.join(media_dir, line)) - tracks.append(track.copy(uri=path)) + tracks.append(track.replace(uri=path)) track = Track() return tracks diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 541fcd6d..83dab871 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -486,7 +486,7 @@ def searchaddpl(context, *args): if not playlist: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) - playlist = playlist.copy(tracks=tracks) + playlist = playlist.replace(tracks=tracks) context.core.playlists.save(playlist) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 81e07b6d..4b81f60e 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -48,7 +48,7 @@ class StreamLibraryProvider(backend.LibraryProvider): try: result = self._scanner.scan(uri) - track = utils.convert_tags_to_track(result.tags).copy( + track = utils.convert_tags_to_track(result.tags).replace( uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index a49ead90..200d7729 100644 --- a/tests/audio/test_utils.py +++ b/tests/audio/test_utils.py @@ -59,7 +59,7 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_no(self): del self.tags['track-number'] - self.check(self.track.copy(track_no=None)) + self.check(self.track.replace(track_no=None)) def test_multiple_track_no(self): self.tags['track-number'].append(9) @@ -67,7 +67,7 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_disc_no(self): del self.tags['album-disc-number'] - self.check(self.track.copy(disc_no=None)) + self.check(self.track.replace(disc_no=None)) def test_multiple_track_disc_no(self): self.tags['album-disc-number'].append(9) @@ -75,15 +75,15 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_name(self): del self.tags['title'] - self.check(self.track.copy(name=None)) + self.check(self.track.replace(name=None)) def test_multiple_track_name(self): self.tags['title'] = ['name1', 'name2'] - self.check(self.track.copy(name='name1; name2')) + self.check(self.track.replace(name='name1; name2')) def test_missing_track_musicbrainz_id(self): del self.tags['musicbrainz-trackid'] - self.check(self.track.copy(musicbrainz_id=None)) + self.check(self.track.replace(musicbrainz_id=None)) def test_multiple_track_musicbrainz_id(self): self.tags['musicbrainz-trackid'].append('id') @@ -91,7 +91,7 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_bitrate(self): del self.tags['bitrate'] - self.check(self.track.copy(bitrate=None)) + self.check(self.track.replace(bitrate=None)) def test_multiple_track_bitrate(self): self.tags['bitrate'].append(1234) @@ -99,15 +99,15 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_genre(self): del self.tags['genre'] - self.check(self.track.copy(genre=None)) + self.check(self.track.replace(genre=None)) def test_multiple_track_genre(self): self.tags['genre'] = ['genre1', 'genre2'] - self.check(self.track.copy(genre='genre1; genre2')) + self.check(self.track.replace(genre='genre1; genre2')) def test_missing_track_date(self): del self.tags['date'] - self.check(self.track.copy(date=None)) + self.check(self.track.replace(date=None)) def test_multiple_track_date(self): self.tags['date'].append(datetime.date(2030, 1, 1)) @@ -115,25 +115,25 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_comment(self): del self.tags['comment'] - self.check(self.track.copy(comment=None)) + self.check(self.track.replace(comment=None)) def test_multiple_track_comment(self): self.tags['comment'] = ['comment1', 'comment2'] - self.check(self.track.copy(comment='comment1; comment2')) + self.check(self.track.replace(comment='comment1; comment2')) def test_missing_track_artist_name(self): del self.tags['artist'] - self.check(self.track.copy(artists=[])) + self.check(self.track.replace(artists=[])) def test_multiple_track_artist_name(self): self.tags['artist'] = ['name1', 'name2'] artists = [Artist(name='name1'), Artist(name='name2')] - self.check(self.track.copy(artists=artists)) + self.check(self.track.replace(artists=artists)) def test_missing_track_artist_musicbrainz_id(self): del self.tags['musicbrainz-artistid'] - artist = list(self.track.artists)[0].copy(musicbrainz_id=None) - self.check(self.track.copy(artists=[artist])) + artist = list(self.track.artists)[0].replace(musicbrainz_id=None) + self.check(self.track.replace(artists=[artist])) def test_multiple_track_artist_musicbrainz_id(self): self.tags['musicbrainz-artistid'].append('id') @@ -141,25 +141,25 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_composer_name(self): del self.tags['composer'] - self.check(self.track.copy(composers=[])) + self.check(self.track.replace(composers=[])) def test_multiple_track_composer_name(self): self.tags['composer'] = ['composer1', 'composer2'] composers = [Artist(name='composer1'), Artist(name='composer2')] - self.check(self.track.copy(composers=composers)) + self.check(self.track.replace(composers=composers)) def test_missing_track_performer_name(self): del self.tags['performer'] - self.check(self.track.copy(performers=[])) + self.check(self.track.replace(performers=[])) def test_multiple_track_performe_name(self): self.tags['performer'] = ['performer1', 'performer2'] performers = [Artist(name='performer1'), Artist(name='performer2')] - self.check(self.track.copy(performers=performers)) + self.check(self.track.replace(performers=performers)) def test_missing_album_name(self): del self.tags['album'] - self.check(self.track.copy(album=None)) + self.check(self.track.replace(album=None)) def test_multiple_album_name(self): self.tags['album'].append('album2') @@ -167,9 +167,9 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_musicbrainz_id(self): del self.tags['musicbrainz-albumid'] - album = self.track.album.copy(musicbrainz_id=None, - images=[]) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(musicbrainz_id=None, + images=[]) + self.check(self.track.replace(album=album)) def test_multiple_album_musicbrainz_id(self): self.tags['musicbrainz-albumid'].append('id') @@ -177,8 +177,8 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_num_tracks(self): del self.tags['track-count'] - album = self.track.album.copy(num_tracks=None) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(num_tracks=None) + self.check(self.track.replace(album=album)) def test_multiple_album_num_tracks(self): self.tags['track-count'].append(9) @@ -186,8 +186,8 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_num_discs(self): del self.tags['album-disc-count'] - album = self.track.album.copy(num_discs=None) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(num_discs=None) + self.check(self.track.replace(album=album)) def test_multiple_album_num_discs(self): self.tags['album-disc-count'].append(9) @@ -195,21 +195,21 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_artist_name(self): del self.tags['album-artist'] - album = self.track.album.copy(artists=[]) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(artists=[]) + self.check(self.track.replace(album=album)) def test_multiple_album_artist_name(self): self.tags['album-artist'] = ['name1', 'name2'] artists = [Artist(name='name1'), Artist(name='name2')] - album = self.track.album.copy(artists=artists) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(artists=artists) + self.check(self.track.replace(album=album)) def test_missing_album_artist_musicbrainz_id(self): del self.tags['musicbrainz-albumartistid'] albumartist = list(self.track.album.artists)[0] - albumartist = albumartist.copy(musicbrainz_id=None) - album = self.track.album.copy(artists=[albumartist]) - self.check(self.track.copy(album=album)) + albumartist = albumartist.replace(musicbrainz_id=None) + album = self.track.album.replace(artists=[albumartist]) + self.check(self.track.replace(album=album)) def test_multiple_album_artist_musicbrainz_id(self): self.tags['musicbrainz-albumartistid'].append('id') @@ -218,30 +218,30 @@ class TagsToTrackTest(unittest.TestCase): def test_stream_organization_track_name(self): del self.tags['title'] self.tags['organization'] = ['organization'] - self.check(self.track.copy(name='organization')) + self.check(self.track.replace(name='organization')) def test_multiple_organization_track_name(self): del self.tags['title'] self.tags['organization'] = ['organization1', 'organization2'] - self.check(self.track.copy(name='organization1; organization2')) + self.check(self.track.replace(name='organization1; organization2')) # TODO: combine all comment types? def test_stream_location_track_comment(self): del self.tags['comment'] self.tags['location'] = ['location'] - self.check(self.track.copy(comment='location')) + self.check(self.track.replace(comment='location')) def test_multiple_location_track_comment(self): del self.tags['comment'] self.tags['location'] = ['location1', 'location2'] - self.check(self.track.copy(comment='location1; location2')) + self.check(self.track.replace(comment='location1; location2')) def test_stream_copyright_track_comment(self): del self.tags['comment'] self.tags['copyright'] = ['copyright'] - self.check(self.track.copy(comment='copyright')) + self.check(self.track.replace(comment='copyright')) def test_multiple_copyright_track_comment(self): del self.tags['comment'] self.tags['copyright'] = ['copyright1', 'copyright2'] - self.check(self.track.copy(comment='copyright1; copyright2')) + self.check(self.track.replace(comment='copyright1; copyright2')) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index e916b670..67dc91f0 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -111,7 +111,7 @@ class BackendEventsTest(unittest.TestCase): def test_playlists_save_sends_playlist_changed_event(self, send): playlist = self.core.playlists.create('foo').get() - playlist = playlist.copy(name='bar') + playlist = playlist.replace(name='bar') send.reset_mock() self.core.playlists.save(playlist).get() diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index a294e6cf..5b5eaa33 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -70,7 +70,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - playlist = self.core.playlists.save(playlist.copy(name='test2')) + playlist = self.core.playlists.save(playlist.replace(name='test2')) self.assertEqual('test2', playlist.name) self.assertEqual(uri2, playlist.uri) self.assertFalse(os.path.exists(path1)) @@ -93,7 +93,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) playlist = self.core.playlists.create('test') - playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + playlist = self.core.playlists.save(playlist.replace(tracks=[track])) path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: @@ -104,7 +104,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_extended_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1), name='Test', length=60000) playlist = self.core.playlists.create('test') - playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + playlist = self.core.playlists.save(playlist.replace(tracks=[track])) path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: @@ -115,7 +115,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_playlists_are_loaded_at_startup(self): track = Track(uri='dummy:track:path2') playlist = self.core.playlists.create('test') - playlist = playlist.copy(tracks=[track]) + playlist = playlist.replace(tracks=[track]) playlist = self.core.playlists.save(playlist) self.assertEqual(len(self.core.playlists.as_list()), 1) @@ -191,7 +191,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): playlist1 = self.core.playlists.create('test1') self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) - playlist2 = playlist1.copy(name='test2') + playlist2 = playlist1.replace(name='test2') playlist2 = self.core.playlists.save(playlist2) self.assertIsNone(self.core.playlists.lookup(playlist1.uri)) self.assertEqual(playlist2, self.core.playlists.lookup(playlist2.uri)) @@ -199,7 +199,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_create_replaces_existing_playlist_with_updated_playlist(self): track = Track(uri=generate_song(1)) playlist1 = self.core.playlists.create('test') - playlist1 = self.core.playlists.save(playlist1.copy(tracks=[track])) + playlist1 = self.core.playlists.save(playlist1.replace(tracks=[track])) self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) playlist2 = self.core.playlists.create('test') @@ -220,7 +220,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_playlist_with_unknown_track(self): track = Track(uri='file:///dev/null') playlist = self.core.playlists.create('test') - playlist = playlist.copy(tracks=[track]) + playlist = playlist.replace(tracks=[track]) playlist = self.core.playlists.save(playlist) self.assertEqual(len(self.core.playlists.as_list()), 1) @@ -244,7 +244,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) playlist = self.core.playlists.lookup('m3u:a.m3u') - playlist = playlist.copy(name='d') + playlist = playlist.replace(name='d') playlist = self.core.playlists.save(playlist) check_order(self.core.playlists.as_list(), ['b', 'c', 'd']) @@ -256,7 +256,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_get_items_returns_item_refs(self): track = Track(uri='dummy:a', name='A', length=60000) playlist = self.core.playlists.create('test') - playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + playlist = self.core.playlists.save(playlist.replace(tracks=[track])) item_refs = self.core.playlists.get_items(playlist.uri) diff --git a/tests/m3u/test_translator.py b/tests/m3u/test_translator.py index c84f12bf..32eb9f3b 100644 --- a/tests/m3u/test_translator.py +++ b/tests/m3u/test_translator.py @@ -22,9 +22,9 @@ encoded_uri = path.path_to_uri(encoded_path) song1_track = Track(uri=song1_uri) song2_track = Track(uri=song2_uri) encoded_track = Track(uri=encoded_uri) -song1_ext_track = song1_track.copy(name='song1') -song2_ext_track = song2_track.copy(name='song2', length=60000) -encoded_ext_track = encoded_track.copy(name='æøå') +song1_ext_track = song1_track.replace(name='song1') +song2_ext_track = song2_track.replace(name='song2', length=60000) +encoded_ext_track = encoded_track.replace(name='æøå') # FIXME use mock instead of tempfile.NamedTemporaryFile diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index ca043d3c..73c3b300 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -103,7 +103,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_searchaddpl_appends_to_existing_playlist(self): playlist = self.core.playlists.create('my favs').get() - playlist = playlist.copy(tracks=[ + playlist = playlist.replace(tracks=[ Track(uri='dummy:x', name='X'), Track(uri='dummy:y', name='y'), ]) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 4e1baf0e..055932fc 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -80,26 +80,26 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertEqual(len(result), 14) def test_track_to_mpd_format_musicbrainz_trackid(self): - track = self.track.copy(musicbrainz_id='foo') + track = self.track.replace(musicbrainz_id='foo') result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_TRACKID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): - album = self.track.album.copy(musicbrainz_id='foo') - track = self.track.copy(album=album) + album = self.track.album.replace(musicbrainz_id='foo') + track = self.track.replace(album=album) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumartistid(self): - artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') - album = self.track.album.copy(artists=[artist]) - track = self.track.copy(album=album) + artist = list(self.track.artists)[0].replace(musicbrainz_id='foo') + album = self.track.album.replace(artists=[artist]) + track = self.track.replace(album=album) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMARTISTID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_artistid(self): - artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') - track = self.track.copy(artists=[artist]) + artist = list(self.track.artists)[0].replace(musicbrainz_id='foo') + track = self.track.replace(artists=[artist]) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) From d2f9733296a313ae38917fd2d01fc2ff6181c02b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 16 Apr 2015 00:28:09 +0200 Subject: [PATCH 073/318] core: Track.id was removed five years ago, update docs. --- mopidy/core/tracklist.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 596f759f..9d4f960b 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -399,25 +399,19 @@ class TracklistController(object): # Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID) filter({'tlid': [1, 2, 3, 4]}) - # Returns track with IDs 1, 5, or 7 - filter({'id': [1, 5, 7]}) - # Returns track with URIs 'xyz' or 'abc' filter({'uri': ['xyz', 'abc']}) - # Returns tracks with ID 1 and URI 'xyz' - filter({'id': [1], 'uri': ['xyz']}) - - # Returns track with a matching ID (1, 3 or 6) and a matching URI - # ('xyz' or 'abc') - filter({'id': [1, 3, 6], 'uri': ['xyz', 'abc']}) + # Returns track with a matching TLIDs (1, 3 or 6) and a + # matching URI ('xyz' or 'abc') + filter({'tlid': [1, 3, 6], 'uri': ['xyz', 'abc']}) :param criteria: on or more criteria to match by :type criteria: dict, of (string, list) pairs :rtype: list of :class:`mopidy.models.TlTrack` .. deprecated:: 1.1 - Providing the criteria via ``kwargs`` is no longer supported. + Providing the criteria via ``kwargs``. """ if kwargs: deprecation.warn('core.tracklist.filter:kwargs_criteria') @@ -426,7 +420,6 @@ class TracklistController(object): tlids = criteria.pop('tlid', []) validation.check_query(criteria, validation.TRACKLIST_FIELDS) validation.check_instances(tlids, int) - # TODO: id=[1, 2, 3] filtering can't possibly be working matches = self._tl_tracks for (key, values) in criteria.items(): From 7459e9c9d847887681ccf6306a632175a4d22ab2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 16 Apr 2015 21:11:06 +0200 Subject: [PATCH 074/318] mpd: Stop using deprecated kwarg based calls --- mopidy/mpd/protocol/current_playlist.py | 14 +++++++------- mopidy/mpd/protocol/music_db.py | 7 +++++-- mopidy/mpd/protocol/playback.py | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index ea815c6a..e406f2ca 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -90,7 +90,7 @@ def delete(context, position): if not tl_tracks: raise exceptions.MpdArgError('Bad song index', command='delete') for (tlid, _) in tl_tracks: - context.core.tracklist.remove(tlid=[tlid]) + context.core.tracklist.remove({'tlid': [tlid]}) @protocol.commands.add('deleteid', tlid=protocol.UINT) @@ -102,7 +102,7 @@ def deleteid(context, tlid): Deletes the song ``SONGID`` from the playlist """ - tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get() + tl_tracks = context.core.tracklist.remove({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') @@ -147,7 +147,7 @@ def moveid(context, tlid, to): the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ - tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() + tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() @@ -185,7 +185,7 @@ def playlistfind(context, tag, needle): - does not add quotes around the tag. """ if tag == 'filename': - tl_tracks = context.core.tracklist.filter(uri=[needle]).get() + tl_tracks = context.core.tracklist.filter({'uri': [needle]}).get() if not tl_tracks: return None position = context.core.tracklist.index(tl_tracks[0]).get() @@ -204,7 +204,7 @@ def playlistid(context, tlid=None): and specifies a single song to display info for. """ if tlid is not None: - tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() + tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() @@ -370,8 +370,8 @@ def swapid(context, tlid1, tlid2): Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). """ - tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get() - tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get() + tl_tracks1 = context.core.tracklist.filter({'tlid': [tlid1]}).get() + tl_tracks2 = context.core.tracklist.filter({'tlid': [tlid2]}).get() if not tl_tracks1 or not tl_tracks2: raise exceptions.MpdNoExistError('No such song') position1 = context.core.tracklist.index(tl_tracks1[0]).get() diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 83dab871..0e59706f 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -6,6 +6,7 @@ import warnings from mopidy.models import Track from mopidy.mpd import exceptions, protocol, translator +from mopidy.utils import deprecation _SEARCH_MAPPING = { 'album': 'album', @@ -142,7 +143,8 @@ def find(context, *args): except ValueError: return - results = context.core.library.search(query=query, exact=True).get() + with deprecation.ignore('core.library.search:empty_query'): + results = context.core.library.search(query=query, exact=True).get() result_tracks = [] if ('artist' not in query and 'albumartist' not in query and @@ -422,7 +424,8 @@ def search(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.search(query).get() + with deprecation.ignore('core.library.search:empty_query'): + results = context.core.library.search(query).get() artists = [_artist_as_track(a) for a in _get_artists(results)] albums = [_album_as_track(a) for a in _get_albums(results)] tracks = _get_tracks(results) diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 6beb4277..a537819c 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -217,7 +217,7 @@ def playid(context, tlid): """ if tlid == -1: return _play_minus_one(context) - tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() + tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') return context.core.playback.play(tl_tracks[0]).get() From ab761c45964fd8b1ccdf7c4c9b8ca9db1d3a37e7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 16 Apr 2015 21:11:59 +0200 Subject: [PATCH 075/318] core: Stop using kwarg based remove call --- mopidy/core/tracklist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 9d4f960b..e0497a9a 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -548,7 +548,7 @@ class TracklistController(object): def _mark_played(self, tl_track): """Internal method for :class:`mopidy.core.PlaybackController`.""" if self.consume and tl_track is not None: - self.remove(tlid=[tl_track.tlid]) + self.remove({'tlid': [tl_track.tlid]}) return True return False From 81fd426caf980fa1a0b4315ba90b529e6909ccbd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 16 Apr 2015 21:12:18 +0200 Subject: [PATCH 076/318] tests: Update tests to not used deprecated kwargs --- tests/core/test_events.py | 2 +- tests/core/test_playlists.py | 2 +- tests/core/test_tracklist.py | 8 +++---- tests/local/test_tracklist.py | 41 ++++++++++++++++++----------------- tests/m3u/test_playlists.py | 1 + 5 files changed, 28 insertions(+), 26 deletions(-) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 67dc91f0..157acffd 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -71,7 +71,7 @@ class BackendEventsTest(unittest.TestCase): self.core.tracklist.add(uris=['dummy:a']).get() send.reset_mock() - self.core.tracklist.remove(uri=['dummy:a']).get() + self.core.tracklist.remove({'uri': ['dummy:a']}).get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 1ccc1815..febff62b 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -240,7 +240,7 @@ class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): return super(DeprecatedFilterPlaylistsTest, self).run(result) def test_filter_returns_matching_playlists(self): - result = self.core.playlists.filter(name='A') + result = self.core.playlists.filter({'name': 'A'}) self.assertEqual(2, len(result)) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 24a9ef0f..1ff089cb 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -64,7 +64,7 @@ class TracklistTest(unittest.TestCase): tl_tracks, self.core.tracklist.tl_tracks[-len(tl_tracks):]) def test_remove_removes_tl_tracks_matching_query(self): - tl_tracks = self.core.tracklist.remove(name=['foo']) + tl_tracks = self.core.tracklist.remove({'name': ['foo']}) self.assertEqual(2, len(tl_tracks)) self.assertListEqual(self.tl_tracks[:2], tl_tracks) @@ -82,7 +82,7 @@ class TracklistTest(unittest.TestCase): self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) def test_filter_returns_tl_tracks_matching_query(self): - tl_tracks = self.core.tracklist.filter(name=['foo']) + tl_tracks = self.core.tracklist.filter({'name': ['foo']}) self.assertEqual(2, len(tl_tracks)) self.assertListEqual(self.tl_tracks[:2], tl_tracks) @@ -95,10 +95,10 @@ class TracklistTest(unittest.TestCase): def test_filter_fails_if_values_isnt_iterable(self): with self.assertRaises(ValueError): - self.core.tracklist.filter(tlid=3) + self.core.tracklist.filter({'tlid': 3}) def test_filter_fails_if_values_is_a_string(self): with self.assertRaises(ValueError): - self.core.tracklist.filter(uri='a') + self.core.tracklist.filter({'uri': 'a'}) # TODO Extract tracklist tests from the local backend tests diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index ca36ac44..f405f218 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -77,53 +77,54 @@ class LocalTracklistProviderTest(unittest.TestCase): def test_filter_by_tlid(self): tl_track = self.controller.tl_tracks[1] self.assertEqual( - [tl_track], self.controller.filter(tlid=[tl_track.tlid])) + [tl_track], self.controller.filter({'tlid': [tl_track.tlid]})) @populate_tracklist def test_filter_by_uri(self): tl_track = self.controller.tl_tracks[1] self.assertEqual( - [tl_track], self.controller.filter(uri=[tl_track.track.uri])) + [tl_track], self.controller.filter({'uri': [tl_track.track.uri]})) @populate_tracklist def test_filter_by_uri_returns_nothing_for_invalid_uri(self): - self.assertEqual([], self.controller.filter(uri=['foobar'])) + self.assertEqual([], self.controller.filter({'uri': ['foobar']})) def test_filter_by_uri_returns_single_match(self): - track = Track(uri='a') - self.controller.add([Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.controller.filter(uri=['a'])[0].track) + t = Track(uri='a') + self.controller.add([Track(uri='z'), t, Track(uri='y')]) + self.assertEqual(t, self.controller.filter({'uri': ['a']})[0].track) def test_filter_by_uri_returns_multiple_matches(self): track = Track(uri='a') self.controller.add([Track(uri='z'), track, track]) - tl_tracks = self.controller.filter(uri=['a']) + tl_tracks = self.controller.filter({'uri': ['a']}) self.assertEqual(track, tl_tracks[0].track) self.assertEqual(track, tl_tracks[1].track) def test_filter_by_uri_returns_nothing_if_no_match(self): self.controller.playlist = Playlist( tracks=[Track(uri='z'), Track(uri='y')]) - self.assertEqual([], self.controller.filter(uri=['a'])) + self.assertEqual([], self.controller.filter({'uri': ['a']})) def test_filter_by_multiple_criteria_returns_elements_matching_all(self): - track1 = Track(uri='a', name='x') - track2 = Track(uri='b', name='x') - track3 = Track(uri='b', name='y') - self.controller.add([track1, track2, track3]) + t1 = Track(uri='a', name='x') + t2 = Track(uri='b', name='x') + t3 = Track(uri='b', name='y') + self.controller.add([t1, t2, t3]) self.assertEqual( - track1, self.controller.filter(uri=['a'], name=['x'])[0].track) + t1, self.controller.filter({'uri': ['a'], 'name': ['x']})[0].track) self.assertEqual( - track2, self.controller.filter(uri=['b'], name=['x'])[0].track) + t2, self.controller.filter({'uri': ['b'], 'name': ['x']})[0].track) self.assertEqual( - track3, self.controller.filter(uri=['b'], name=['y'])[0].track) + t3, self.controller.filter({'uri': ['b'], 'name': ['y']})[0].track) def test_filter_by_criteria_that_is_not_present_in_all_elements(self): track1 = Track() track2 = Track(uri='b') track3 = Track() self.controller.add([track1, track2, track3]) - self.assertEqual(track2, self.controller.filter(uri=['b'])[0].track) + self.assertEqual( + track2, self.controller.filter({'uri': ['b']})[0].track) @populate_tracklist def test_clear(self): @@ -233,17 +234,17 @@ class LocalTracklistProviderTest(unittest.TestCase): track1 = self.controller.tracks[1] track2 = self.controller.tracks[2] version = self.controller.version - self.controller.remove(uri=[track1.uri]) + self.controller.remove({'uri': [track1.uri]}) self.assertLess(version, self.controller.version) self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) @populate_tracklist def test_removing_track_that_does_not_exist_does_nothing(self): - self.controller.remove(uri=['/nonexistant']) + self.controller.remove({'uri': ['/nonexistant']}) def test_removing_from_empty_playlist_does_nothing(self): - self.controller.remove(uri=['/nonexistant']) + self.controller.remove({'uri': ['/nonexistant']}) @populate_tracklist def test_remove_lists(self): @@ -251,7 +252,7 @@ class LocalTracklistProviderTest(unittest.TestCase): track1 = self.controller.tracks[1] track2 = self.controller.tracks[2] version = self.controller.version - self.controller.remove(uri=[track0.uri, track2.uri]) + self.controller.remove({'uri': [track0.uri, track2.uri]}) self.assertLess(version, self.controller.version) self.assertNotIn(track0, self.controller.tracks) self.assertNotIn(track2, self.controller.tracks) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 5b5eaa33..a8caf8fd 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -275,6 +275,7 @@ class DeprecatedM3UPlaylistsProviderTest(M3UPlaylistsProviderTest): def run(self, result=None): with deprecation.ignore(ids=['core.playlists.filter', + 'core.playlists.filter:kwargs_criteria', 'core.playlists.get_playlists']): return super(DeprecatedM3UPlaylistsProviderTest, self).run(result) From 09027854c651d8e3f0c46452c71bd364ff170bc4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Apr 2015 00:05:46 +0200 Subject: [PATCH 077/318] validation: Reject iterators as core arguments iter() always never re-wraps an iterator, so 'iter(i) is iter(i)' tests if we wrapped a container or if we already had an iterator. I also tried types.GeneratorType and inspect helpers but they did not work for this use case. --- mopidy/utils/validation.py | 4 +++- tests/utils/test_validation.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/validation.py b/mopidy/utils/validation.py index 4897f513..7eedba20 100644 --- a/mopidy/utils/validation.py +++ b/mopidy/utils/validation.py @@ -22,11 +22,13 @@ DISTINCT_FIELDS = { # TODO: _check_iterable(check, msg, **kwargs) + [check(a) for a in arg]? def _check_iterable(arg, msg, **kwargs): - """Ensure we have an iterable which is not a string.""" + """Ensure we have an iterable which is not a string or an iterator""" if isinstance(arg, compat.string_types): raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) elif not isinstance(arg, collections.Iterable): raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) + elif iter(arg) is iter(arg): + raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) def check_choice(arg, choices, msg='Expected one of {choices}, not {arg!r}'): diff --git a/tests/utils/test_validation.py b/tests/utils/test_validation.py index 67b42a4c..f211c003 100644 --- a/tests/utils/test_validation.py +++ b/tests/utils/test_validation.py @@ -72,6 +72,8 @@ def test_check_instances_with_invalid_values(): validation.check_instances(None, compat.string_types) with raises(exceptions.ValidationError): validation.check_instances([None], compat.string_types) + with raises(exceptions.ValidationError): + validation.check_instances(iter(['abc']), compat.string_types) def test_check_instances_error_message(): @@ -110,7 +112,7 @@ def test_check_field_error_message(): def test_check_query_invalid_values(): - for value in '', None, 'foo', 123, [''], [None]: + for value in '', None, 'foo', 123, [''], [None], iter(['abc']): with raises(exceptions.ValidationError): validation.check_query({'any': value}) @@ -155,6 +157,8 @@ def test_check_uris_with_invalid_values(): validation.check_uris([None]) with raises(exceptions.ValidationError): validation.check_uris(['foobar:', 'foobar']) + with raises(exceptions.ValidationError): + validation.check_uris(iter(['http://example.com'])) def test_check_uris_error_message(): From 5acc3ea564887614de04b64ea0a28ef225165cbe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 17 Apr 2015 22:58:19 +0200 Subject: [PATCH 078/318] docs: Fix syntax error --- docs/changelog.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0011d60b..ab2b87ad 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,10 +12,10 @@ 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`) From 9871d999bb911c81d1772747cf155baefad4dd49 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Apr 2015 23:04:48 +0200 Subject: [PATCH 079/318] mpd: Replace filterwarnings with deprecation helper --- mopidy/mpd/protocol/music_db.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 0e59706f..0350fc21 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals import functools import itertools -import warnings from mopidy.models import Track from mopidy.mpd import exceptions, protocol, translator @@ -174,10 +173,9 @@ def findadd(context, *args): results = context.core.library.search(query=query, exact=True).get() - with warnings.catch_warnings(): + with deprecation.ignore('core.tracklist.add:tracks_arg'): # TODO: for now just use tracks as other wise we have to lookup the # tracks we just got from the search. - warnings.filterwarnings('ignore', 'tracklist.add.*"tracks" argument.*') context.core.tracklist.add(tracks=_get_tracks(results)).get() @@ -452,10 +450,9 @@ def searchadd(context, *args): results = context.core.library.search(query).get() - with warnings.catch_warnings(): + with deprecation.ignore('core.tracklist.add:tracks_arg'): # TODO: for now just use tracks as other wise we have to lookup the # tracks we just got from the search. - warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') context.core.tracklist.add(_get_tracks(results)).get() From 7af570418f3966b9678e6c1b0444a3f9097a943b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Apr 2015 23:05:50 +0200 Subject: [PATCH 080/318] mpd: Stop using properties to get values --- mopidy/mpd/protocol/playback.py | 8 ++++---- mopidy/mpd/protocol/status.py | 25 +++++++++++++------------ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index a537819c..26890d10 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -16,7 +16,7 @@ def consume(context, state): 1. When consume is activated, each song played is removed from playlist. """ - context.core.tracklist.consume = state + context.core.tracklist.set_consume(state) @protocol.commands.add('crossfade', seconds=protocol.UINT) @@ -279,7 +279,7 @@ def random(context, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ - context.core.tracklist.random = state + context.core.tracklist.set_random(state) @protocol.commands.add('repeat', state=protocol.BOOL) @@ -291,7 +291,7 @@ def repeat(context, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ - context.core.tracklist.repeat = state + context.core.tracklist.set_repeat(state) @protocol.commands.add('replay_gain_mode') @@ -409,7 +409,7 @@ def single(context, state): single is activated, playback is stopped after current song, or song is repeated if the ``repeat`` mode is enabled. """ - context.core.tracklist.single = state + context.core.tracklist.set_single(state) @protocol.commands.add('stop') diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index aa78b387..9e7c5180 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -172,20 +172,20 @@ def status(context): - ``elapsed``: Higher resolution means time in seconds with three decimal places for millisecond precision. """ + tl_track = context.core.playback.get_current_tl_track() + futures = { - 'tracklist.length': context.core.tracklist.length, - 'tracklist.version': context.core.tracklist.version, + 'tracklist.length': context.core.tracklist.get_length(), + 'tracklist.version': context.core.tracklist.get_version(), 'mixer.volume': context.core.mixer.get_volume(), - 'tracklist.consume': context.core.tracklist.consume, - 'tracklist.random': context.core.tracklist.random, - 'tracklist.repeat': context.core.tracklist.repeat, - 'tracklist.single': context.core.tracklist.single, - 'playback.state': context.core.playback.state, - 'playback.current_tl_track': context.core.playback.current_tl_track, - 'tracklist.index': ( - context.core.tracklist.index( - context.core.playback.current_tl_track.get())), - 'playback.time_position': context.core.playback.time_position, + 'tracklist.consume': context.core.tracklist.get_consume(), + 'tracklist.random': context.core.tracklist.get_random(), + 'tracklist.repeat': context.core.tracklist.get_repeat(), + 'tracklist.single': context.core.tracklist.get_single(), + 'playback.state': context.core.playback.get_state(), + 'playback.current_tl_track': tl_track, + 'tracklist.index': context.core.tracklist.index(tl_track.get()), + 'playback.time_position': context.core.playback.get_time_position(), } pykka.get_all(futures.values()) result = [ @@ -199,6 +199,7 @@ def status(context): ('xfade', _status_xfade(futures)), ('state', _status_state(futures)), ] + # TODO: add nextsong and nextsongid if futures['playback.current_tl_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) From 14910730bd70d9135e291a0796d17cedbef5dc3a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Apr 2015 23:13:03 +0200 Subject: [PATCH 081/318] mpd: Cleanup confusing argument names, fixes #1134 Makes sure not to use tlid when we mean songpos and also be a bit more consistent about what we call things across functions. --- mopidy/mpd/protocol/audio_output.py | 4 ++-- mopidy/mpd/protocol/current_playlist.py | 24 ++++++++++++------------ mopidy/mpd/protocol/playback.py | 18 +++++++++--------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 565ea3d0..059c505d 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -8,7 +8,7 @@ def disableoutput(context, outputid): """ *musicpd.org, audio output section:* - ``disableoutput`` + ``disableoutput {ID}`` Turns an output off. """ @@ -25,7 +25,7 @@ def enableoutput(context, outputid): """ *musicpd.org, audio output section:* - ``enableoutput`` + ``enableoutput {ID}`` Turns an output on. """ diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index e406f2ca..823c2e57 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -73,8 +73,8 @@ def addid(context, uri, songpos=None): return ('Id', tl_tracks[0].tlid) -@protocol.commands.add('delete', position=protocol.RANGE) -def delete(context, position): +@protocol.commands.add('delete', songrange=protocol.RANGE) +def delete(context, songrange): """ *musicpd.org, current playlist section:* @@ -82,8 +82,8 @@ def delete(context, position): Deletes a song from the playlist. """ - start = position.start - end = position.stop + start = songrange.start + end = songrange.stop if end is None: end = context.core.tracklist.length.get() tl_tracks = context.core.tracklist.slice(start, end).get() @@ -119,8 +119,8 @@ def clear(context): context.core.tracklist.clear() -@protocol.commands.add('move', position=protocol.RANGE, to=protocol.UINT) -def move_range(context, position, to): +@protocol.commands.add('move', songrange=protocol.RANGE, to=protocol.UINT) +def move_range(context, songrange, to): """ *musicpd.org, current playlist section:* @@ -129,8 +129,8 @@ def move_range(context, position, to): Moves the song at ``FROM`` or range of songs at ``START:END`` to ``TO`` in the playlist. """ - start = position.start - end = position.stop + start = songrange.start + end = songrange.stop if end is None: end = context.core.tracklist.length.get() context.core.tracklist.move(start, end, to) @@ -320,8 +320,8 @@ def plchangesposid(context, version): return result -@protocol.commands.add('shuffle', position=protocol.RANGE) -def shuffle(context, position=None): +@protocol.commands.add('shuffle', songrange=protocol.RANGE) +def shuffle(context, songrange=None): """ *musicpd.org, current playlist section:* @@ -330,10 +330,10 @@ def shuffle(context, position=None): Shuffles the current playlist. ``START:END`` is optional and specifies a range of songs. """ - if position is None: + if songrange is None: start, end = None, None else: - start, end = position.start, position.stop + start, end = songrange.start, songrange.stop context.core.tracklist.shuffle(start, end) diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 26890d10..21597ee3 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -145,8 +145,8 @@ def pause(context, state=None): context.core.playback.resume() -@protocol.commands.add('play', tlid=protocol.INT) -def play(context, tlid=None): +@protocol.commands.add('play', songpos=protocol.INT) +def play(context, songpos=None): """ *musicpd.org, playback section:* @@ -170,13 +170,13 @@ def play(context, tlid=None): - issues ``play 6`` without quotes around the argument. """ - if tlid is None: + if songpos is None: return context.core.playback.play().get() - elif tlid == -1: + elif songpos == -1: return _play_minus_one(context) try: - tl_track = context.core.tracklist.slice(tlid, tlid + 1).get()[0] + tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] return context.core.playback.play(tl_track).get() except IndexError: raise exceptions.MpdArgError('Bad song index') @@ -324,8 +324,8 @@ def replay_gain_status(context): return 'off' # TODO -@protocol.commands.add('seek', tlid=protocol.UINT, seconds=protocol.UINT) -def seek(context, tlid, seconds): +@protocol.commands.add('seek', songpos=protocol.UINT, seconds=protocol.UINT) +def seek(context, songpos, seconds): """ *musicpd.org, playback section:* @@ -339,8 +339,8 @@ def seek(context, tlid, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ tl_track = context.core.playback.current_tl_track.get() - if context.core.tracklist.index(tl_track).get() != tlid: - play(context, tlid) + if context.core.tracklist.index(tl_track).get() != songpos: + play(context, songpos) context.core.playback.seek(seconds * 1000).get() From b631f1111b3660ff5509934b44b4bd8f1c45f9aa Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Apr 2015 23:18:32 +0200 Subject: [PATCH 082/318] mpd: Reduce thread switches by reusing values from core --- mopidy/mpd/protocol/playback.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 21597ee3..2087ea97 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -183,18 +183,21 @@ def play(context, songpos=None): def _play_minus_one(context): - if (context.core.playback.state.get() == PlaybackState.PLAYING): + playback_state = context.core.playback.get_state().get() + if playback_state == PlaybackState.PLAYING: return # Nothing to do - elif (context.core.playback.state.get() == PlaybackState.PAUSED): + elif playback_state == PlaybackState.PAUSED: return context.core.playback.resume().get() - elif context.core.playback.current_tl_track.get() is not None: - tl_track = context.core.playback.current_tl_track.get() - return context.core.playback.play(tl_track).get() - elif context.core.tracklist.slice(0, 1).get(): - tl_track = context.core.tracklist.slice(0, 1).get()[0] - return context.core.playback.play(tl_track).get() - else: - return # Fail silently + + current_tl_track = context.core.playback.get_current_tl_track().get() + if current_tl_track is not None: + return context.core.playback.play(current_tl_track).get() + + tl_tracks = context.core.tracklist.slice(0, 1).get() + if tl_tracks: + return context.core.playback.play(tl_tracks[0]).get() + + return # Fail silently @protocol.commands.add('playid', tlid=protocol.INT) From 84546488c1d7d5381f016e47d37d32d8034b4a69 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Apr 2015 23:32:02 +0200 Subject: [PATCH 083/318] mpd: Remove core attribute usage --- mopidy/mpd/protocol/current_playlist.py | 26 ++++++++++++++----------- mopidy/mpd/protocol/playback.py | 11 ++++++----- mopidy/mpd/protocol/reflection.py | 2 +- mopidy/mpd/protocol/status.py | 2 +- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 823c2e57..f93722ee 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -64,10 +64,14 @@ def addid(context, uri, songpos=None): """ if not uri: raise exceptions.MpdNoExistError('No such song') - if songpos is not None and songpos > context.core.tracklist.length.get(): + + length = context.core.tracklist.get_length() + if songpos is not None and songpos > length.get(): raise exceptions.MpdArgError('Bad song index') + tl_tracks = context.core.tracklist.add( uris=[uri], at_position=songpos).get() + if not tl_tracks: raise exceptions.MpdNoExistError('No such song') return ('Id', tl_tracks[0].tlid) @@ -85,7 +89,7 @@ def delete(context, songrange): start = songrange.start end = songrange.stop if end is None: - end = context.core.tracklist.length.get() + end = context.core.tracklist.get_length().get() tl_tracks = context.core.tracklist.slice(start, end).get() if not tl_tracks: raise exceptions.MpdArgError('Bad song index', command='delete') @@ -132,7 +136,7 @@ def move_range(context, songrange, to): start = songrange.start end = songrange.stop if end is None: - end = context.core.tracklist.length.get() + end = context.core.tracklist.get_length().get() context.core.tracklist.move(start, end, to) @@ -211,7 +215,7 @@ def playlistid(context, tlid=None): return translator.track_to_mpd_format(tl_tracks[0], position=position) else: return translator.tracks_to_mpd_format( - context.core.tracklist.tl_tracks.get()) + context.core.tracklist.get_tl_tracks().get()) @protocol.commands.add('playlistinfo') @@ -236,7 +240,7 @@ def playlistinfo(context, parameter=None): tracklist_slice = protocol.RANGE(parameter) start, end = tracklist_slice.start, tracklist_slice.stop - tl_tracks = context.core.tracklist.tl_tracks.get() + tl_tracks = context.core.tracklist.get_tl_tracks().get() if start and start > len(tl_tracks): raise exceptions.MpdArgError('Bad song index') if end and end > len(tl_tracks): @@ -279,10 +283,10 @@ def plchanges(context, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - tracklist_version = context.core.tracklist.version.get() + tracklist_version = context.core.tracklist.get_version().get() if version < tracklist_version: return translator.tracks_to_mpd_format( - context.core.tracklist.tl_tracks.get()) + context.core.tracklist.get_tl_tracks().get()) elif version == tracklist_version: # A version match could indicate this is just a metadata update, so # check for a stream ref and let the client know about the change. @@ -290,7 +294,7 @@ def plchanges(context, version): if stream_title is None: return None - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.get_current_tl_track().get() position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( tl_track, position=position, stream_title=stream_title) @@ -311,10 +315,10 @@ def plchangesposid(context, version): ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed - if int(version) != context.core.tracklist.version.get(): + if int(version) != context.core.tracklist.get_version().get(): result = [] for (position, (tlid, _)) in enumerate( - context.core.tracklist.tl_tracks.get()): + context.core.tracklist.get_tl_tracks().get()): result.append(('cpos', position)) result.append(('Id', tlid)) return result @@ -346,7 +350,7 @@ def swap(context, songpos1, songpos2): Swaps the positions of ``SONG1`` and ``SONG2``. """ - tracks = context.core.tracklist.tracks.get() + tracks = context.core.tracklist.get_tracks().get() song1 = tracks[songpos1] song2 = tracks[songpos2] del tracks[songpos1] diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 2087ea97..ce3174d7 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -135,9 +135,10 @@ def pause(context, state=None): if state is None: deprecation.warn('mpd.protocol.playback.pause:state_arg') - if (context.core.playback.state.get() == PlaybackState.PLAYING): + playback_state = context.core.playback.get_state().get() + if (playback_state == PlaybackState.PLAYING): context.core.playback.pause() - elif (context.core.playback.state.get() == PlaybackState.PAUSED): + elif (playback_state == PlaybackState.PAUSED): context.core.playback.resume() elif state: context.core.playback.pause() @@ -341,7 +342,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.get_current_tl_track().get() if context.core.tracklist.index(tl_track).get() != songpos: play(context, songpos) context.core.playback.seek(seconds * 1000).get() @@ -356,7 +357,7 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.get_current_tl_track().get() if not tl_track or tl_track.tlid != tlid: playid(context, tlid) context.core.playback.seek(seconds * 1000).get() @@ -373,7 +374,7 @@ def seekcur(context, time): '+' or '-', then the time is relative to the current playing position. """ if time.startswith(('+', '-')): - position = context.core.playback.time_position.get() + position = context.core.playback.get_time_position().get() position += protocol.INT(time) * 1000 context.core.playback.seek(position).get() else: diff --git a/mopidy/mpd/protocol/reflection.py b/mopidy/mpd/protocol/reflection.py index 7feccca1..a3608a96 100644 --- a/mopidy/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -107,4 +107,4 @@ def urlhandlers(context): """ return [ ('handler', uri_scheme) - for uri_scheme in context.core.uri_schemes.get()] + for uri_scheme in context.core.get_uri_schemes().get()] diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 9e7c5180..16e9d013 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -34,7 +34,7 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.get_current_tl_track().get() stream_title = context.core.playback.get_stream_title().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() From 6a7005be1e2570352d7643045f0ef7ff9fa72bd0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Apr 2015 20:42:52 +0200 Subject: [PATCH 084/318] core: Add `tlid` argument to index calls. Should save clients from having to pass tl_track models around. --- mopidy/core/tracklist.py | 23 +++++++++++---- tests/core/test_tracklist.py | 54 ++++++++++++++++++++++++++++++++++- tests/local/test_tracklist.py | 12 +------- 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index e0497a9a..de875081 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -200,19 +200,32 @@ class TracklistController(object): # Methods - def index(self, tl_track): + def index(self, tl_track=None, tlid=None): """ The position of the given track in the tracklist. :param tl_track: the track to find the index of :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :param tlid: of the track to find the index of + :type tlid: TLID number or :class:`None` :rtype: :class:`int` or :class:`None` + + .. versionchanged:: 1.1 + Added the *tlid* parameter """ tl_track is None or validation.check_instance(tl_track, TlTrack) - try: - return self._tl_tracks.index(tl_track) - except ValueError: - return None + tlid is None or validation.check_integer(tlid, min=0) + + if tl_track is not None: + try: + return self._tl_tracks.index(tl_track) + except ValueError: + pass + elif tlid is not None: + for i, tl_track in enumerate(self._tl_tracks): + if tl_track.tlid == tlid: + return i + return None def eot_track(self, tl_track): """ diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 1ff089cb..d9f918a8 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -5,7 +5,7 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Track +from mopidy.models import TlTrack, Track from mopidy.utils import deprecation @@ -102,3 +102,55 @@ class TracklistTest(unittest.TestCase): self.core.tracklist.filter({'uri': 'a'}) # TODO Extract tracklist tests from the local backend tests + + +class TracklistIndexTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.tracks = [ + Track(uri='dummy1:a', name='foo'), + Track(uri='dummy1:b', name='foo'), + Track(uri='dummy1:c', name='bar'), + ] + + def lookup(uris): + return {u: [t for t in self.tracks if t.uri == u] for u in uris} + + self.core = core.Core(mixer=None, backends=[]) + self.core.library.lookup = mock.Mock() + self.core.library.lookup.side_effect = lookup + + self.tl_tracks = self.core.tracklist.add(uris=[ + t.uri for t in self.tracks]) + + def test_index_returns_index_of_track(self): + self.assertEqual(0, self.core.tracklist.index(self.tl_tracks[0])) + self.assertEqual(1, self.core.tracklist.index(self.tl_tracks[1])) + self.assertEqual(2, self.core.tracklist.index(self.tl_tracks[2])) + + def test_index_returns_none_if_item_not_found(self): + tl_track = TlTrack(0, Track()) + self.assertEqual(self.core.tracklist.index(tl_track), None) + + def test_index_returns_none_if_called_with_none(self): + self.assertEqual(self.core.tracklist.index(None), None) + + def test_index_errors_out_for_invalid_tltrack(self): + with self.assertRaises(ValueError): + self.core.tracklist.index('abc') + + def test_index_return_index_when_called_with_tlids(self): + tl_tracks = self.tl_tracks + self.assertEqual(0, self.core.tracklist.index(tlid=tl_tracks[0].tlid)) + self.assertEqual(1, self.core.tracklist.index(tlid=tl_tracks[1].tlid)) + self.assertEqual(2, self.core.tracklist.index(tlid=tl_tracks[2].tlid)) + + def test_index_returns_none_if_tlid_not_found(self): + self.assertEqual(self.core.tracklist.index(tlid=123), None) + + def test_index_returns_none_if_called_with_tlid_none(self): + self.assertEqual(self.core.tracklist.index(tlid=None), None) + + def test_index_errors_out_for_invalid_tlid(self): + with self.assertRaises(ValueError): + self.core.tracklist.index(tlid=-1) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index f405f218..a0add637 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -8,7 +8,7 @@ import pykka from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor -from mopidy.models import Playlist, TlTrack, Track +from mopidy.models import Playlist, Track from mopidy.utils import deprecation from tests import dummy_audio, path_to_data_dir @@ -176,16 +176,6 @@ class LocalTracklistProviderTest(unittest.TestCase): tl_tracks = self.controller.add(self.controller.tracks[1:2]) self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) - def test_index_returns_index_of_track(self): - tl_tracks = self.controller.add(self.tracks) - self.assertEqual(0, self.controller.index(tl_tracks[0])) - self.assertEqual(1, self.controller.index(tl_tracks[1])) - self.assertEqual(2, self.controller.index(tl_tracks[2])) - - def test_index_returns_none_if_item_not_found(self): - tl_track = TlTrack(0, Track()) - self.assertEqual(self.controller.index(tl_track), None) - @populate_tracklist def test_move_single(self): self.controller.move(0, 0, 2) From 691abb2431547f811aa303a1c5684e341332b98d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Apr 2015 20:54:15 +0200 Subject: [PATCH 085/318] core: Stop making tl track copies all over the place --- mopidy/core/tracklist.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index de875081..b0396d53 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -264,13 +264,13 @@ class TracklistController(object): """ tl_track is None or validation.check_instance(tl_track, TlTrack) - if not self.get_tl_tracks(): + if not self._tl_tracks: return None if self.get_random() and not self._shuffled: if self.get_repeat() or not tl_track: logger.debug('Shuffling tracks') - self._shuffled = self.get_tl_tracks() + self._shuffled = self._tl_tracks[:] random.shuffle(self._shuffled) if self.get_random(): @@ -280,14 +280,14 @@ class TracklistController(object): return None if tl_track is None: - return self.get_tl_tracks()[0] + return self._tl_tracks[0] next_index = self.index(tl_track) + 1 if self.get_repeat(): - next_index %= len(self.get_tl_tracks()) + next_index %= len(self._tl_tracks) try: - return self.get_tl_tracks()[next_index] + return self._tl_tracks[next_index] except IndexError: return None @@ -314,7 +314,9 @@ class TracklistController(object): if position in (None, 0): return None - return self.get_tl_tracks()[position - 1] + # Note that since we know we are at position 1-n we know this will + # never be out bounds for the tl_tracks list. + return self._tl_tracks[position - 1] def add(self, tracks=None, at_position=None, uri=None, uris=None): """ @@ -567,7 +569,7 @@ class TracklistController(object): def _trigger_tracklist_changed(self): if self.get_random(): - self._shuffled = self.get_tl_tracks() + self._shuffled = self._tl_tracks[:] random.shuffle(self._shuffled) else: self._shuffled = [] From aab143aeec79f63f5df1c3433ede655bb1bb71b2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Apr 2015 20:59:13 +0200 Subject: [PATCH 086/318] core: Cleanup internals of next_track a bit --- mopidy/core/tracklist.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index b0396d53..6ffb0f60 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -274,23 +274,22 @@ class TracklistController(object): random.shuffle(self._shuffled) if self.get_random(): - try: + if self._shuffled: return self._shuffled[0] - except IndexError: - return None + return None if tl_track is None: - return self._tl_tracks[0] + next_index = 0 + else: + next_index = self.index(tl_track) + 1 - next_index = self.index(tl_track) + 1 if self.get_repeat(): next_index %= len(self._tl_tracks) - - try: - return self._tl_tracks[next_index] - except IndexError: + elif next_index >= len(self._tl_tracks): return None + return self._tl_tracks[next_index] + def previous_track(self, tl_track): """ Returns the track that will be played if calling From a88d3cf61369c9c71fd87f62dc50c5704ea2a0b9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Apr 2015 15:47:36 +0200 Subject: [PATCH 087/318] core: Add get_*_tlid helpers --- mopidy/core/tracklist.py | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 6ffb0f60..13877795 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -227,6 +227,20 @@ class TracklistController(object): return i return None + def get_eot_tlid(self): + """ + The TLID of the track that will be played after the given track. + + Not necessarily the same track as :meth:`get_next_tlid`. + + :rtype: TLID or :class:`None` + + .. versionadded:: 1.1 + """ + + current_tl_track = self.core.playback.get_current_tl_track() + return getattr(self.eot_track(current_tl_track), 'tlid', None) + def eot_track(self, tl_track): """ The track that will be played after the given track. @@ -248,6 +262,23 @@ class TracklistController(object): # shared. return self.next_track(tl_track) + def get_next_tlid(self): + """ + The tlid of the track that will be played if calling + :meth:`mopidy.core.PlaybackController.next()`. + + For normal playback this is the next track in the tracklist. If repeat + is enabled the next track can loop around the tracklist. When random is + enabled this should be a random track, all tracks should be played once + before the tracklist repeats. + + :rtype: TLID or :class:`None` + + .. versionadded:: 1.1 + """ + current_tl_track = self.core.playback.get_current_tl_track() + return getattr(self.next_track(current_tl_track), 'tlid', None) + def next_track(self, tl_track): """ The track that will be played if calling @@ -290,6 +321,22 @@ class TracklistController(object): return self._tl_tracks[next_index] + def get_previous_tlid(self): + """ + Returns the TLID of the track that will be played if calling + :meth:`mopidy.core.PlaybackController.previous()`. + + For normal playback this is the previous track in the tracklist. If + random and/or consume is enabled it should return the current track + instead. + + :rtype: TLID or :class:`None` + + .. versionadded:: 1.1 + """ + current_tl_track = self.core.playback.get_current_tl_track() + return getattr(self.previous_track(current_tl_track), 'tlid', None) + def previous_track(self, tl_track): """ Returns the track that will be played if calling From 2e705cf8d4cb61fb06f25a30dcb1b47540255e2d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Apr 2015 16:52:02 +0200 Subject: [PATCH 088/318] core: Add pending depraction for *_track methods --- mopidy/core/tracklist.py | 3 +++ mopidy/utils/deprecation.py | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 13877795..7850acc1 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -251,6 +251,7 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + deprecation.warn('core.tracklist.eot_track', pending=True) tl_track is None or validation.check_instance(tl_track, TlTrack) if self.get_single() and self.get_repeat(): return tl_track @@ -293,6 +294,7 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + deprecation.warn('core.tracklist.next_track', pending=True) tl_track is None or validation.check_instance(tl_track, TlTrack) if not self._tl_tracks: @@ -350,6 +352,7 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + deprecation.warn('core.tracklist.previous_track', pending=True) tl_track is None or validation.check_instance(tl_track, TlTrack) if self.get_repeat() or self.get_consume() or self.get_random(): diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index db263e6d..be3cc650 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -45,13 +45,25 @@ _MESSAGES = { 'core.tracklist.remove:kwargs_criteria': 'tracklist.remove() with "kwargs" as criteria is deprecated', + 'core.tracklist.eot_track': + 'tracklist.eot_track() is deprecated, use tracklist.get_eot_tlid()', + 'core.tracklist.next_track': + 'tracklist.next_track() is deprecated, use tracklist.get_next_tlid()', + 'core.tracklist.previous_track': + 'tracklist.previous_track() is deprecated, use ' + 'tracklist.get_previous_tlid()', + 'models.immutable.copy': 'ImmutableObject.copy() is deprecated, use ImmutableObject.replace()', } -def warn(msg_id): - warnings.warn(_MESSAGES.get(msg_id, msg_id), DeprecationWarning) +def warn(msg_id, pending=False): + if pending: + category = PendingDeprecationWarning + else: + category = DeprecationWarning + warnings.warn(_MESSAGES.get(msg_id, msg_id), category) @contextlib.contextmanager From fba4069cfd8b77b93718a5e5d101382ec6e1d095 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Apr 2015 21:01:20 +0200 Subject: [PATCH 089/318] core: Make index return current index when missing args --- mopidy/core/tracklist.py | 6 ++++++ tests/core/test_tracklist.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 7850acc1..8b630403 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -204,6 +204,9 @@ class TracklistController(object): """ The position of the given track in the tracklist. + If neither *tl_track* or *tlid* is given we return the index of + the currently playing track. + :param tl_track: the track to find the index of :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :param tlid: of the track to find the index of @@ -216,6 +219,9 @@ class TracklistController(object): tl_track is None or validation.check_instance(tl_track, TlTrack) tlid is None or validation.check_integer(tlid, min=0) + if tl_track is None and tlid is None: + tl_track = self.core.playback.get_current_tl_track() + if tl_track is not None: try: return self._tl_tracks.index(tl_track) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index d9f918a8..6339a18c 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -117,9 +117,11 @@ class TracklistIndexTest(unittest.TestCase): return {u: [t for t in self.tracks if t.uri == u] for u in uris} self.core = core.Core(mixer=None, backends=[]) - self.core.library.lookup = mock.Mock() + self.core.library = mock.Mock(spec=core.LibraryController) self.core.library.lookup.side_effect = lookup + self.core.playback = mock.Mock(spec=core.PlaybackController) + self.tl_tracks = self.core.tracklist.add(uris=[ t.uri for t in self.tracks]) @@ -154,3 +156,12 @@ class TracklistIndexTest(unittest.TestCase): def test_index_errors_out_for_invalid_tlid(self): with self.assertRaises(ValueError): self.core.tracklist.index(tlid=-1) + + def test_index_without_args_returns_current_tl_track_index(self): + self.core.playback.get_current_tl_track.side_effect = [ + None, self.tl_tracks[0], self.tl_tracks[1], self.tl_tracks[2]] + + self.assertEqual(None, self.core.tracklist.index()) + self.assertEqual(0, self.core.tracklist.index()) + self.assertEqual(1, self.core.tracklist.index()) + self.assertEqual(2, self.core.tracklist.index()) From a4cb56337591d2e8cdd6d783eb6344c0bda430bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Wn=C4=99trzak?= Date: Mon, 20 Apr 2015 20:04:10 +0200 Subject: [PATCH 090/318] Add info about new online web client --- docs/ext/mopster.png | Bin 0 -> 82375 bytes docs/ext/web.rst | 15 +++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 docs/ext/mopster.png diff --git a/docs/ext/mopster.png b/docs/ext/mopster.png new file mode 100644 index 0000000000000000000000000000000000000000..2855c4cf91f35320cf5f8d3fae698a3edc9187da GIT binary patch literal 82375 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 E0QwWCcmMzZ literal 0 HcmV?d00001 diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 7355dbf9..4c22a02c 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -134,6 +134,21 @@ To install, run:: pip install Mopidy-Simple-Webclient +Mopster +======= + +https://github.com/cowbell/mopster + +Simple web client hosted online written in Ember.js and styled using basic Bootstrap +by Wojciech Wnętrzak. + +.. image:: /ext/mopster.png + :width: 1275 + :height: 628 + +To use, just visit http://mopster.cowbell-labs.com + + Mopidy-WebSettings ================== From 9cec66696f618e03732a630a8b7fd7f700a89539 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 21 Apr 2015 00:55:24 +0200 Subject: [PATCH 091/318] core: Fix comments and docstrings per review comments --- mopidy/core/tracklist.py | 16 ++++++++-------- mopidy/utils/deprecation.py | 8 +++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 8b630403..01da6019 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -209,8 +209,8 @@ class TracklistController(object): :param tl_track: the track to find the index of :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` - :param tlid: of the track to find the index of - :type tlid: TLID number or :class:`None` + :param tlid: TLID of the track to find the index of + :type tlid: :class:`int` or :class:`None` :rtype: :class:`int` or :class:`None` .. versionchanged:: 1.1 @@ -237,9 +237,9 @@ class TracklistController(object): """ The TLID of the track that will be played after the given track. - Not necessarily the same track as :meth:`get_next_tlid`. + Not necessarily the same TLID as returned by :meth:`get_next_tlid`. - :rtype: TLID or :class:`None` + :rtype: :class:`int` or :class:`None` .. versionadded:: 1.1 """ @@ -279,7 +279,7 @@ class TracklistController(object): enabled this should be a random track, all tracks should be played once before the tracklist repeats. - :rtype: TLID or :class:`None` + :rtype: :class:`int` or :class:`None` .. versionadded:: 1.1 """ @@ -338,7 +338,7 @@ class TracklistController(object): random and/or consume is enabled it should return the current track instead. - :rtype: TLID or :class:`None` + :rtype: :class:`int` or :class:`None` .. versionadded:: 1.1 """ @@ -369,8 +369,8 @@ class TracklistController(object): if position in (None, 0): return None - # Note that since we know we are at position 1-n we know this will - # never be out bounds for the tl_tracks list. + # Since we know we are not at zero we have to be somewhere in the range + # 1 - len(tracks) Thus 'position - 1' will always be within the list. return self._tl_tracks[position - 1] def add(self, tracks=None, at_position=None, uri=None, uris=None): diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index be3cc650..a650d79e 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -46,11 +46,13 @@ _MESSAGES = { 'tracklist.remove() with "kwargs" as criteria is deprecated', 'core.tracklist.eot_track': - 'tracklist.eot_track() is deprecated, use tracklist.get_eot_tlid()', + 'tracklist.eot_track() is pending deprecation, use ' + 'tracklist.get_eot_tlid()', 'core.tracklist.next_track': - 'tracklist.next_track() is deprecated, use tracklist.get_next_tlid()', + 'tracklist.next_track() is pending deprecation, use ' + 'tracklist.get_next_tlid()', 'core.tracklist.previous_track': - 'tracklist.previous_track() is deprecated, use ' + 'tracklist.previous_track() is pending deprecation, use ' 'tracklist.get_previous_tlid()', 'models.immutable.copy': From a38bc6a4f6d6e96b87954021516855cef75b2101 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 21 Apr 2015 23:00:43 +0200 Subject: [PATCH 092/318] docs: Add changelog entry for PR#1136 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ab2b87ad..e8953ce8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,10 @@ Core API - Update core methods to do strict input checking. (Fixes: :issue:`#700`) +- Add ``tlid`` alternatives to methods that take ``tl_track`` and also add + ``get_{eot,next,previous}_tlid`` methods as light weight alternatives to the + ``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`) + Models ------ From c996072040411acbb69a7dd6aecce27a773d3c53 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Apr 2015 23:32:18 +0200 Subject: [PATCH 093/318] docs: Wrap lines, sort sections by name --- docs/ext/web.rst | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 4c22a02c..f07fb40b 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -134,21 +134,6 @@ To install, run:: pip install Mopidy-Simple-Webclient -Mopster -======= - -https://github.com/cowbell/mopster - -Simple web client hosted online written in Ember.js and styled using basic Bootstrap -by Wojciech Wnętrzak. - -.. image:: /ext/mopster.png - :width: 1275 - :height: 628 - -To use, just visit http://mopster.cowbell-labs.com - - Mopidy-WebSettings ================== @@ -158,6 +143,21 @@ A web extension for changing settings. Used by the Pi MusicBox distribution for Raspberry Pi, but also usable for other projects. +Mopster +======= + +https://github.com/cowbell/mopster + +Simple web client hosted online written in Ember.js and styled using basic +Bootstrap by Wojciech Wnętrzak. + +.. image:: /ext/mopster.png + :width: 1275 + :height: 628 + +To use, just visit http://mopster.cowbell-labs.com/. + + Other web clients ================= From a62293c3165bbf34ffb02713e2630fc455f6c5a5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 22 Apr 2015 20:55:51 +0200 Subject: [PATCH 094/318] core: Add play(tlid) support --- mopidy/core/playback.py | 20 +++++++++++++++++--- mopidy/utils/deprecation.py | 3 +++ tests/core/test_playback.py | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 135e1828..99deee05 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -265,18 +265,32 @@ class PlaybackController(object): self.set_state(PlaybackState.PAUSED) self._trigger_track_playback_paused() - def play(self, tl_track=None): + def play(self, tl_track=None, tlid=None): """ Play the given track, or if the given track is :class:`None`, play the currently active track. :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :param tlid: TLID of the track to play + :type tlid: :class:`int` or :class:`None` """ tl_track is None or validation.check_instance(tl_track, models.TlTrack) - self._play(tl_track, on_error_step=1) + tlid is None or validation.check_integer(tlid, min=0) + # TODO: check one of or none for args + if tl_track: + deprecation.warn('core.playback.play:tl_track_kwarg', pending=True) + + self._play(tl_track=tl_track, tlid=tlid, on_error_step=1) + + def _play(self, tl_track=None, tlid=None, on_error_step=1): + if tl_track is None and tlid is not None: + for tl_track in self.core.tracklist.get_tl_tracks(): + if tl_track.tlid == tlid: + break + else: + tl_track = None - def _play(self, tl_track=None, on_error_step=1): if tl_track is None: if self.get_state() == PlaybackState.PAUSED: return self.resume() diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index a650d79e..7b1b915e 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -30,6 +30,9 @@ _MESSAGES = { 'core.playback.set_mute': 'playback.set_mute() is deprecated', 'core.playback.get_volume': 'playback.get_volume() is deprecated', 'core.playback.set_volume': 'playback.set_volume() is deprecated', + 'core.playback.play:tl_track_kwargs': + 'playback.play() with "tl_track" argument is pending deprecation use ' + '"tlid" instead', # Deprecated features in core playlists: 'core.playlists.filter': 'playlists.filter() is deprecated', diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index a113e034..1837ac80 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -8,6 +8,7 @@ import pykka from mopidy import backend, core from mopidy.models import Track +from mopidy.utils import deprecation from tests import dummy_audio as audio @@ -698,3 +699,22 @@ class CorePlaybackWithOldBackendTest(unittest.TestCase): c = core.Core(mixer=None, backends=[b]) c.tracklist.add(uris=['dummy1:a']) c.playback.play() # No TypeError == test passed. + + +class TestPlay(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.backend = mock.Mock() + self.backend.uri_schemes.get.return_value = ['dummy'] + self.core = core.Core(backends=[self.backend]) + + self.tracks = [Track(uri='dummy:a', length=1234), + Track(uri='dummy:b', length=1234)] + + with deprecation.ignore('core.tracklist.add:tracks_arg'): + self.tl_tracks = self.core.tracklist.add(tracks=self.tracks) + + def test_play_tlid(self): + self.core.playback.play(tlid=self.tl_tracks[1].tlid) + self.backend.playback.change_track.assert_called_once_with( + self.tl_tracks[1].track) From 142ddcfc8ab56240aa975bcc13461fde85dac4da Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 22 Apr 2015 20:57:17 +0200 Subject: [PATCH 095/318] docs: Add play by tlid to changelog --- docs/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e8953ce8..77ac5320 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,7 +24,8 @@ Core API - Add ``tlid`` alternatives to methods that take ``tl_track`` and also add ``get_{eot,next,previous}_tlid`` methods as light weight alternatives to the - ``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`) + ``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`, + :issue:`1140`) Models ------ From 9bb278f00e6c86bc55f32f83437b534152788d75 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 22 Apr 2015 22:48:43 +0200 Subject: [PATCH 096/318] core: Make history controller traversable Fixes mopidy/mopidy.js#6 --- docs/changelog.rst | 2 ++ mopidy/core/history.py | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ebc42c44..0c470386 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,8 @@ v1.0.1 (UNRELEASED) Bug fix release. +- Core: Make the new history controller available for use. (Fixes: :js:`6`) + - Audio: Software volume control has been reworked to greatly reduce the delay between changing the volume and the change taking effect. (Fixes: :issue:`1097`) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index f0d5e9d4..ae697e8e 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) class HistoryController(object): + pykka_traversable = True def __init__(self): self._history = [] From c897877a71982a5f9760333a3d9c1b785b9a728f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Apr 2015 21:48:07 +0200 Subject: [PATCH 097/318] docs: Manually split tracklist class documentation --- docs/api/core.rst | 80 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index 27ab2f57..2d1be5dc 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -14,6 +14,60 @@ frontends and the backends. .. autoclass:: mopidy.core.Core :members: +Tracklist controller +==================== + +.. autoclass:: mopidy.core.TracklistController + +Manages everything related to the tracks we are currently playing. This is +likely where you need to start as only tracks that are in the *tracklist* can be +played. + +Manipulating +------------ + +.. automethod:: mopidy.core.TracklistController.add +.. automethod:: mopidy.core.TracklistController.remove +.. automethod:: mopidy.core.TracklistController.clear +.. automethod:: mopidy.core.TracklistController.move +.. automethod:: mopidy.core.TracklistController.shuffle + +Current state +------------- + +.. automethod:: mopidy.core.TracklistController.get_tl_tracks +.. automethod:: mopidy.core.TracklistController.index +.. automethod:: mopidy.core.TracklistController.get_version + +.. automethod:: mopidy.core.TracklistController.get_length +.. automethod:: mopidy.core.TracklistController.get_tracks + +.. automethod:: mopidy.core.TracklistController.slice +.. automethod:: mopidy.core.TracklistController.filter + +Future state +------------ + +.. automethod:: mopidy.core.TracklistController.get_eot_tlid +.. automethod:: mopidy.core.TracklistController.get_next_tlid +.. automethod:: mopidy.core.TracklistController.get_previous_tlid + +.. automethod:: mopidy.core.TracklistController.eot_track +.. automethod:: mopidy.core.TracklistController.next_track +.. automethod:: mopidy.core.TracklistController.previous_track + +Options +------- + +.. automethod:: mopidy.core.TracklistController.get_consume +.. automethod:: mopidy.core.TracklistController.set_consume +.. automethod:: mopidy.core.TracklistController.get_random +.. automethod:: mopidy.core.TracklistController.set_random +.. automethod:: mopidy.core.TracklistController.get_repeat +.. automethod:: mopidy.core.TracklistController.set_repeat +.. automethod:: mopidy.core.TracklistController.get_single +.. automethod:: mopidy.core.TracklistController.set_single + Playback controller =================== @@ -27,16 +81,6 @@ seek, and volume control. .. autoclass:: mopidy.core.PlaybackController :members: - -Tracklist controller -==================== - -Manages everything related to the tracks we are currently playing. - -.. autoclass:: mopidy.core.TracklistController - :members: - - History controller ================== @@ -77,3 +121,19 @@ Core listener .. autoclass:: mopidy.core.CoreListener :members: + +Deprecated API features +======================= + +TracklistController +------------------- + +.. autoattribute:: mopidy.core.TracklistController.tl_tracks +.. autoattribute:: mopidy.core.TracklistController.tracks +.. autoattribute:: mopidy.core.TracklistController.version +.. autoattribute:: mopidy.core.TracklistController.length + +.. autoattribute:: mopidy.core.TracklistController.consume +.. autoattribute:: mopidy.core.TracklistController.random +.. autoattribute:: mopidy.core.TracklistController.repeat +.. autoattribute:: mopidy.core.TracklistController.single From b80bf615b56d45289834b3a9a9289b0455e8f3f9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Apr 2015 21:51:23 +0200 Subject: [PATCH 098/318] core: Add exact param to docstring --- mopidy/core/library.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 7140f2cd..903f5bf6 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -279,6 +279,8 @@ class LibraryController(object): :type query: dict :param uris: zero or more URI roots to limit the search to :type uris: list of strings or :class:`None` + :param exact: if the search should use exact matching + :type exact: :class:`bool` :rtype: list of :class:`mopidy.models.SearchResult` .. versionadded:: 1.0 From 58641100cebeaaf2c1fdb6001088f884b38cc0a6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Apr 2015 21:55:03 +0200 Subject: [PATCH 099/318] core: Add examples that shows that search is AND --- mopidy/core/library.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 903f5bf6..e60ae57d 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -275,6 +275,9 @@ class LibraryController(object): # "file:///media/music" and "spotify:" search({'any': ['a']}, uris=['file:///media/music', 'spotify:']) + # Returns results matching artist 'xyz' and 'abc' in any backend + search({'artist': ['xyz', 'abc']}) + :param query: one or more queries to search for :type query: dict :param uris: zero or more URI roots to limit the search to From d4c695ac75018ba8845fe7b2ccc8867ef4ee355b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Apr 2015 23:08:25 +0200 Subject: [PATCH 100/318] mpd: Split browse and playlist name to uri caching --- docs/changelog.rst | 3 +++ mopidy/mpd/uri_mapper.py | 20 ++++++++++++----- tests/mpd/protocol/test_regression.py | 32 ++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0c470386..647ca651 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,9 @@ Bug fix release. - HTTP: Fix threading bug that would cause duplicate delivery of WS messages. +- MPD: Fix case where a playlist that is present in both browse and as a listed + playlist breaks the MPD frontend protocol output. (Fixes :issue:`1120`) + v1.0.0 (2015-03-25) =================== diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 08c7f689..89dfd619 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -2,8 +2,12 @@ from __future__ import absolute_import, unicode_literals import re +# TOOD: refactor this into a generic mapper that does not know about browse +# or playlists and then use one instance for each case? + class MpdUriMapper(object): + """ Maintains the mappings between uniquified MPD names and URIs. """ @@ -17,7 +21,8 @@ class MpdUriMapper(object): def __init__(self, core=None): self.core = core self._uri_from_name = {} - self._name_from_uri = {} + self._browse_name_from_uri = {} + self._playlist_name_from_uri = {} def _create_unique_name(self, name, uri): stripped_name = self._invalid_browse_chars.sub(' ', name) @@ -30,13 +35,16 @@ class MpdUriMapper(object): i += 1 return name - def insert(self, name, uri): + def insert(self, name, uri, playlist=False): """ Create a unique and MPD compatible name that maps to the given URI. """ name = self._create_unique_name(name, uri) self._uri_from_name[name] = uri - self._name_from_uri[uri] = name + if playlist: + self._playlist_name_from_uri[uri] = name + else: + self._browse_name_from_uri[uri] = name return name def uri_from_name(self, name): @@ -56,7 +64,7 @@ class MpdUriMapper(object): if not playlist_ref.name: continue name = self._invalid_playlist_chars.sub('|', playlist_ref.name) - self.insert(name, playlist_ref.uri) + self.insert(name, playlist_ref.uri, playlist=True) def playlist_uri_from_name(self, name): """ @@ -70,6 +78,6 @@ class MpdUriMapper(object): """ Helper function to retrieve the unique MPD playlist name from its URI. """ - if uri not in self._name_from_uri: + if uri not in self._playlist_name_from_uri: self.refresh_playlists_mapping() - return self._name_from_uri[uri] + return self._playlist_name_from_uri[uri] diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 6fb59afd..60a71ee8 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import random -from mopidy.models import Track +from mopidy.models import Playlist, Ref, Track from tests.mpd import protocol @@ -175,3 +175,33 @@ class IssueGH137RegressionTest(protocol.BaseTestCase): u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"') self.assertInResponse('ACK [2@0] {list} Invalid unquoted character') + + +class IssueGH1120RegressionTest(protocol.BaseTestCase): + """ + The issue: https://github.com/mopidy/mopidy/issues/1120 + + How to reproduce: + + - A playlist must be in both browse results and playlists + - Call for instance ``lsinfo "/"`` to populate the cache with the + playlist name from the playlist backend. + - Call ``lsinfo "/dummy"`` to override the playlist name with the browse + name. + - Call ``lsinfo "/"`` and we now have an invalid name with ``/`` in it. + + """ + + def test(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.playlist(name='Top 100 tracks', uri='dummy:/1')], + } + self.backend.playlists.set_dummy_playlists([ + Playlist(name='Top 100 tracks', uri='dummy:/1'), + ]) + + response1 = self.send_request('lsinfo "/"') + self.send_request('lsinfo "/dummy"') + + response2 = self.send_request('lsinfo "/"') + self.assertEqual(response1, response2) From 00cff144a4cfff9a54994b8cbb9f89bbcb4f7c6b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Apr 2015 23:09:32 +0200 Subject: [PATCH 101/318] mpd: Minor code cleanups in URI mapper helper --- mopidy/mpd/uri_mapper.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 89dfd619..9e7ec2dd 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -51,20 +51,21 @@ class MpdUriMapper(object): """ Return the uri for the given MPD name. """ - if name in self._uri_from_name: - return self._uri_from_name[name] + return self._uri_from_name.get(name) def refresh_playlists_mapping(self): """ Maintain map between playlists and unique playlist names to be used by MPD. """ - if self.core is not None: - for playlist_ref in self.core.playlists.as_list().get(): - if not playlist_ref.name: - continue - name = self._invalid_playlist_chars.sub('|', playlist_ref.name) - self.insert(name, playlist_ref.uri, playlist=True) + if self.core is None: + return + + for playlist_ref in self.core.playlists.as_list().get(): + if not playlist_ref.name: + continue + name = self._invalid_playlist_chars.sub('|', playlist_ref.name) + self.insert(name, playlist_ref.uri, playlist=True) def playlist_uri_from_name(self, name): """ From f4dcd598ac55885c280ab29632e0484211a4b487 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 23 Apr 2015 23:25:41 +0200 Subject: [PATCH 102/318] docs: Add references to PRs --- docs/changelog.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 647ca651..0bf39e7c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,7 +14,7 @@ Bug fix release. - Audio: Software volume control has been reworked to greatly reduce the delay between changing the volume and the change taking effect. (Fixes: - :issue:`1097`) + :issue:`1097`, PR: :issue:`1101`) - Audio: As a side effect of the previous bug fix, software volume is no longer tied to the PulseAudio application volume when using ``pulsesink``. This @@ -23,12 +23,15 @@ Bug fix release. - Audio: Update scanner to decode all media it finds. This should fix cases where the scanner hangs on non-audio files like video. The scanner will now - also let us know if we found any decodeable audio. (Fixes: :issue:`726`) + also let us know if we found any decodeable audio. (Fixes: :issue:`726`, PR: + issue:`1124`) - HTTP: Fix threading bug that would cause duplicate delivery of WS messages. + (PR: :issue:`1127`) - MPD: Fix case where a playlist that is present in both browse and as a listed - playlist breaks the MPD frontend protocol output. (Fixes :issue:`1120`) + playlist breaks the MPD frontend protocol output. (Fixes :issue:`1120`, PR: + :issue:`1142`) v1.0.0 (2015-03-25) From 310e9109c17a429ab247a7d5d38481b5ffa2f4b9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 23 Apr 2015 23:34:09 +0200 Subject: [PATCH 103/318] docs: Add release date for v1.0.1 --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0bf39e7c..3b0336a0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,7 +5,7 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.1 (UNRELEASED) +v1.0.1 (2015-04-23) =================== Bug fix release. From 9c793a38ff1e86bc397627ab895194ca53a0a885 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 23 Apr 2015 23:35:45 +0200 Subject: [PATCH 104/318] Bump version to 1.0.1 --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 388bb9f0..8dff0012 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.0' +__version__ = '1.0.1' diff --git a/tests/test_version.py b/tests/test_version.py index 932cc639..3d284121 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -55,5 +55,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('0.19.2', '0.19.3') self.assertVersionLess('0.19.3', '0.19.4') self.assertVersionLess('0.19.4', '0.19.5') - self.assertVersionLess('0.19.5', __version__) - self.assertVersionLess(__version__, '1.0.1') + self.assertVersionLess('0.19.5', '1.0.0') + self.assertVersionLess('1.0.0', __version__) + self.assertVersionLess(__version__, '1.0.2') From 0afe8ef54cc30c824594ee4d7e963b9a66d3190c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 23 Apr 2015 23:40:20 +0200 Subject: [PATCH 105/318] docs: Improve core actor docs --- docs/api/core.rst | 35 ++++++++++++++++++++++++++++++++--- mopidy/core/actor.py | 18 ++++++------------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index 2d1be5dc..ad7b821b 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -8,11 +8,34 @@ Core API :synopsis: Core API for use by frontends The core API is the interface that is used by frontends like -:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is inbetween the -frontends and the backends. +:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is in between the +frontends and the backends. Don't forget that you will be accessing core +as a Pykka actor. .. autoclass:: mopidy.core.Core - :members: + + .. automethod:: get_uri_schemes + + .. automethod:: get_version + + .. autoattribute:: tracklist + :annotation: + + .. autoattribute:: playback + :annotation: + + .. autoattribute:: library + :annotation: + + .. autoattribute:: playlists + :annotation: + + .. autoattribute:: mixer + :annotation: + + .. autoattribute:: history + :annotation: + Tracklist controller ==================== @@ -125,6 +148,12 @@ Core listener Deprecated API features ======================= +Core +---- + +.. autoattribute:: mopidy.core.Core.version +.. autoattribute:: mopidy.core.Core.uri_schemes + TracklistController ------------------- diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index d2454f64..c3967f6a 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -23,28 +23,22 @@ class Core( mixer.MixerListener): library = None - """The library controller. An instance of - :class:`mopidy.core.LibraryController`.""" + """An instance of :class:`~mopidy.core.LibraryController`""" history = None - """The playback history controller. An instance of - :class:`mopidy.core.HistoryController`.""" + """An instance of :class:`~mopidy.core.HistoryController`""" mixer = None - """The mixer controller. An instance of - :class:`mopidy.core.MixerController`.""" + """An instance of :class:`~mopidy.core.MixerController`""" playback = None - """The playback controller. An instance of - :class:`mopidy.core.PlaybackController`.""" + """An instance of :class:`~mopidy.core.PlaybackController`""" playlists = None - """The playlists controller. An instance of - :class:`mopidy.core.PlaylistsController`.""" + """An instance of :class:`~mopidy.core.PlaylistsController`""" tracklist = None - """The tracklist controller. An instance of - :class:`mopidy.core.TracklistController`.""" + """An instance of :class:`~mopidy.core.TracklistController`""" def __init__(self, config=None, mixer=None, backends=None, audio=None): super(Core, self).__init__() From 907f920f1824cc4215a57583cf7171f1e11bb8b4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 00:13:30 +0200 Subject: [PATCH 106/318] docs: Make it more clear what each part of core does right away --- docs/api/core.rst | 58 +++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index ad7b821b..70ed598f 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -14,38 +14,44 @@ as a Pykka actor. .. autoclass:: mopidy.core.Core + .. attribute:: tracklist + + Manages everything related to the list of tracks we will play. + See :class:`~mopidy.core.TracklistController`. + + .. attribute:: playback + + Manages playback state and the current playing track. + See :class:`~mopidy.core.PlaybackController`. + + .. attribute:: library + + Manages the music library, e.g. searching and browsing for music. + See :class:`~mopidy.core.LibraryController`. + + .. attribute:: playlists + + Manages stored playlists. See :class:`~mopidy.core.PlaylistsController`. + + .. attribute:: mixer + + Manages volume and muting. See :class:`~mopidy.core.MixerController`. + + .. attribute:: history + + Keeps record of what tracks have been played. + See :class:`~mopidy.core.HistoryController`. + .. automethod:: get_uri_schemes .. automethod:: get_version - .. autoattribute:: tracklist - :annotation: - - .. autoattribute:: playback - :annotation: - - .. autoattribute:: library - :annotation: - - .. autoattribute:: playlists - :annotation: - - .. autoattribute:: mixer - :annotation: - - .. autoattribute:: history - :annotation: - Tracklist controller ==================== .. autoclass:: mopidy.core.TracklistController -Manages everything related to the tracks we are currently playing. This is -likely where you need to start as only tracks that are in the *tracklist* can be -played. - Manipulating ------------ @@ -107,8 +113,6 @@ seek, and volume control. History controller ================== -Keeps record of what tracks have been played. - .. autoclass:: mopidy.core.HistoryController :members: @@ -116,8 +120,6 @@ Keeps record of what tracks have been played. Playlists controller ==================== -Manages persistence of playlists. - .. autoclass:: mopidy.core.PlaylistsController :members: @@ -125,8 +127,6 @@ Manages persistence of playlists. Library controller ================== -Manages the music library, e.g. searching for tracks to be added to a playlist. - .. autoclass:: mopidy.core.LibraryController :members: @@ -134,8 +134,6 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. Mixer controller ================ -Manages volume and muting. - .. autoclass:: mopidy.core.MixerController :members: From b6b1bb2489cf01f2edbc5a492e3dd8d0f74864c9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 00:14:01 +0200 Subject: [PATCH 107/318] docs: Add warning above deprecated section in core --- docs/api/core.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api/core.rst b/docs/api/core.rst index 70ed598f..f03835e1 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -146,6 +146,10 @@ Core listener Deprecated API features ======================= +.. warning:: + Though these features still work, they are slated to go away in the next + major Mopidy release. + Core ---- From 996af72af7abc6dbfc409dbf034f0c183ef75c4d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 00:14:22 +0200 Subject: [PATCH 108/318] docs: Refresh PlaybackController documentation --- docs/api/core.rst | 54 +++++++++++++++++++++++++++++++++++------ mopidy/core/playback.py | 6 +++-- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index f03835e1..e4a4515b 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -101,14 +101,41 @@ Options Playback controller =================== -Manages playback, with actions like play, pause, stop, next, previous, -seek, and volume control. - -.. autoclass:: mopidy.core.PlaybackState - :members: - .. autoclass:: mopidy.core.PlaybackController - :members: + +Playback control +---------------- + +.. automethod:: mopidy.core.PlaybackController.play +.. automethod:: mopidy.core.PlaybackController.next +.. automethod:: mopidy.core.PlaybackController.previous +.. automethod:: mopidy.core.PlaybackController.stop +.. automethod:: mopidy.core.PlaybackController.pause +.. automethod:: mopidy.core.PlaybackController.resume +.. automethod:: mopidy.core.PlaybackController.seek + +Current track +------------- + +.. automethod:: mopidy.core.PlaybackController.get_current_tl_track +.. automethod:: mopidy.core.PlaybackController.get_current_track +.. automethod:: mopidy.core.PlaybackController.get_stream_title +.. automethod:: mopidy.core.PlaybackController.get_time_position + +Playback states +--------------- + +.. automethod:: mopidy.core.PlaybackController.get_state +.. automethod:: mopidy.core.PlaybackController.set_state + +.. class:: mopidy.core.PlaybackState + + .. attribute:: STOPPED + :annotation: = 'stopped' + .. attribute:: PLAYING + :annotation: = 'playing' + .. attribute:: PAUSED + :annotation: = 'paused' History controller ================== @@ -168,3 +195,16 @@ TracklistController .. autoattribute:: mopidy.core.TracklistController.random .. autoattribute:: mopidy.core.TracklistController.repeat .. autoattribute:: mopidy.core.TracklistController.single + +PlaylistsController +------------------- + +.. automethod:: mopidy.core.PlaybackController.get_mute +.. automethod:: mopidy.core.PlaybackController.get_volume + +.. autoattribute:: mopidy.core.PlaybackController.current_tl_track +.. autoattribute:: mopidy.core.PlaybackController.current_track +.. autoattribute:: mopidy.core.PlaybackController.state +.. autoattribute:: mopidy.core.PlaybackController.time_position +.. autoattribute:: mopidy.core.PlaybackController.mute +.. autoattribute:: mopidy.core.PlaybackController.volume diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 99deee05..2b1bbbc3 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -267,8 +267,10 @@ class PlaybackController(object): def play(self, tl_track=None, tlid=None): """ - Play the given track, or if the given track is :class:`None`, play the - currently active track. + Play the given track, or if the given tl_track and tlid is + :class:`None`, play the currently active track. + + Note that the track **must** already be in the tracklist. :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` From 88f2905133f6d89c21a15e04363382de15bf9f7c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 00:18:53 +0200 Subject: [PATCH 109/318] core: Refresh LibraryController documentation --- docs/api/core.rst | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index e4a4515b..28d13d8c 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -137,6 +137,18 @@ Playback states .. attribute:: PAUSED :annotation: = 'paused' +Library controller +================== + +.. class:: mopidy.core.LibraryController + +.. automethod:: mopidy.core.LibraryController.browse +.. automethod:: mopidy.core.LibraryController.search +.. automethod:: mopidy.core.LibraryController.lookup +.. automethod:: mopidy.core.LibraryController.refresh +.. automethod:: mopidy.core.LibraryController.get_images +.. automethod:: mopidy.core.LibraryController.get_distinct + History controller ================== @@ -150,14 +162,6 @@ Playlists controller .. autoclass:: mopidy.core.PlaylistsController :members: - -Library controller -================== - -.. autoclass:: mopidy.core.LibraryController - :members: - - Mixer controller ================ @@ -208,3 +212,8 @@ PlaylistsController .. autoattribute:: mopidy.core.PlaybackController.time_position .. autoattribute:: mopidy.core.PlaybackController.mute .. autoattribute:: mopidy.core.PlaybackController.volume + +LibraryController +----------------- + +.. automethod:: mopidy.core.LibraryController.find_exaxt From 830a19ca70dcc37fd0903fdc9e4bef4bfdf21b30 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 00:24:19 +0200 Subject: [PATCH 110/318] core: Refresh PlaylistsController documentation --- docs/api/core.rst | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index 28d13d8c..8aab0def 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -149,18 +149,25 @@ Library controller .. automethod:: mopidy.core.LibraryController.get_images .. automethod:: mopidy.core.LibraryController.get_distinct -History controller -================== - -.. autoclass:: mopidy.core.HistoryController - :members: - - Playlists controller ==================== -.. autoclass:: mopidy.core.PlaylistsController - :members: +.. class:: mopidy.core.PlaylistsController + +Fetching +-------- + +.. automethod:: mopidy.core.PlaylistsController.as_list +.. automethod:: mopidy.core.PlaylistsController.get_items +.. automethod:: mopidy.core.PlaylistsController.lookup +.. automethod:: mopidy.core.PlaylistsController.refresh + +Manipulating +------------ + +.. automethod:: mopidy.core.PlaylistsController.create +.. automethod:: mopidy.core.PlaylistsController.save +.. automethod:: mopidy.core.PlaylistsController.delete Mixer controller ================ @@ -168,6 +175,12 @@ Mixer controller .. autoclass:: mopidy.core.MixerController :members: +History controller +================== + +.. autoclass:: mopidy.core.HistoryController + :members: + Core listener ============= @@ -216,4 +229,12 @@ PlaylistsController LibraryController ----------------- -.. automethod:: mopidy.core.LibraryController.find_exaxt +.. automethod:: mopidy.core.LibraryController.find_exact + +PlaybackController +------------------ + +.. automethod:: mopidy.core.PlaylistsController.filter +.. automethod:: mopidy.core.PlaylistsController.get_playlists + +.. autoattribute:: mopidy.core.PlaylistsController.playlists From 461061f1be6b09eea8b3f7f5f93dc2b01dcd2955 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 00:30:40 +0200 Subject: [PATCH 111/318] docs: Refresh of remaining parts of core API docs --- docs/api/core.rst | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index 8aab0def..38703222 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -10,7 +10,8 @@ Core API The core API is the interface that is used by frontends like :mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is in between the frontends and the backends. Don't forget that you will be accessing core -as a Pykka actor. +as a Pykka actor. If you are only interested in being notified about changes +in core see :class:`~mopidy.core.CoreListener`. .. autoclass:: mopidy.core.Core @@ -172,17 +173,23 @@ Manipulating Mixer controller ================ -.. autoclass:: mopidy.core.MixerController - :members: +.. class:: mopidy.core.MixerController + +.. automethod:: mopidy.core.MixerController.get_mute +.. automethod:: mopidy.core.MixerController.set_mute +.. automethod:: mopidy.core.MixerController.get_volume +.. automethod:: mopidy.core.MixerController.set_volume History controller ================== -.. autoclass:: mopidy.core.HistoryController - :members: +.. class:: mopidy.core.HistoryController -Core listener -============= +.. automethod:: mopidy.core.HistoryController.get_history +.. automethod:: mopidy.core.HistoryController.get_length + +Core events +=========== .. autoclass:: mopidy.core.CoreListener :members: From 3fe104875b947da48a36456a2ad873ac264c3216 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Fri, 24 Apr 2015 09:50:14 +0100 Subject: [PATCH 112/318] docs: Glossary typo --- docs/glossary.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/glossary.rst b/docs/glossary.rst index 19c799d4..5247ba97 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -6,7 +6,7 @@ Glossary backend A part of Mopidy providing music library, playlist storage and/or - playback capability to the :term:`core`. Mopidy have a backend for each + playback capability to the :term:`core`. Mopidy has a backend for each music store or music service it supports. See :ref:`backend-api` for details. From b47c0cb8b5706eaf5b1980e8496f09f4b6b40088 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Fri, 24 Apr 2015 11:28:35 +0100 Subject: [PATCH 113/318] docs: Update add method documentation --- mopidy/core/tracklist.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 01da6019..dfcd503a 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -375,26 +375,29 @@ class TracklistController(object): def add(self, tracks=None, at_position=None, uri=None, uris=None): """ - Add the track or list of tracks to the tracklist. + Add tracks to the tracklist. If ``uri`` is given instead of ``tracks``, the URI is looked up in the library and the resulting tracks are added to the tracklist. - If ``uris`` is given instead of ``tracks``, the URIs are looked up in - the library and the resulting tracks are added to the tracklist. + If ``uris`` is given instead of ``uri`` or ``tracks``, the URIs are + looked up in the library and the resulting tracks are added to the + tracklist. - If ``at_position`` is given, the tracks placed at the given position in - the tracklist. If ``at_position`` is not given, the tracks are appended - to the end of the tracklist. + If ``at_position`` is given, the tracks are inserted at the given + position in the tracklist. If ``at_position`` is not given, the tracks + are appended to the end of the tracklist. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param tracks: tracks to add - :type tracks: list of :class:`mopidy.models.Track` - :param at_position: position in tracklist to add track + :type tracks: list of :class:`mopidy.models.Track` or :class:`None` + :param at_position: position in tracklist to add tracks :type at_position: int or :class:`None` :param uri: URI for tracks to add - :type uri: string + :type uri: string or :class:`None` + :param uris: list of URIs for tracks to add + :type uris: list of string or :class:`None` :rtype: list of :class:`mopidy.models.TlTrack` .. versionadded:: 1.0 From 4a48c381d6a9779056dfcac97afde7c77aeec442 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Fri, 24 Apr 2015 16:44:18 +0100 Subject: [PATCH 114/318] docs: Make plurals in LibraryController consistent --- mopidy/core/library.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index e60ae57d..ca0a159d 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -137,7 +137,8 @@ class LibraryController(object): Unknown URIs or URIs the corresponding backend couldn't find anything for will simply return an empty list for that URI. - :param list uris: list of URIs to find images for + :param uris: list of URIs to find images for + :type uris: list of string :rtype: {uri: tuple of :class:`mopidy.models.Image`} .. versionadded:: 1.0 @@ -170,7 +171,7 @@ class LibraryController(object): def lookup(self, uri=None, uris=None): """ - Lookup the given URI. + Lookup the given URIs. If the URI expands to multiple tracks, the returned list will contain them all. @@ -180,7 +181,7 @@ class LibraryController(object): :param uris: track URIs :type uris: list of string or :class:`None` :rtype: list of :class:`mopidy.models.Track` if uri was set or - a {uri: list of :class:`mopidy.models.Track`} if uris was set. + {uri: list of :class:`mopidy.models.Track`} if uris was set. .. versionadded:: 1.0 The ``uris`` argument. @@ -281,7 +282,7 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict :param uris: zero or more URI roots to limit the search to - :type uris: list of strings or :class:`None` + :type uris: list of string or :class:`None` :param exact: if the search should use exact matching :type exact: :class:`bool` :rtype: list of :class:`mopidy.models.SearchResult` From b3ea425fd0f14c0afd60e344c817496fc7853372 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 19:26:02 +0200 Subject: [PATCH 115/318] tests: Fix IssueGH1120RegressionTest flakiness --- tests/mpd/protocol/test_regression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index ac897c93..ff0141f2 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -219,7 +219,7 @@ class IssueGH1120RegressionTest(protocol.BaseTestCase): 'dummy:/': [Ref.playlist(name='Top 100 tracks', uri='dummy:/1')], } self.backend.playlists.set_dummy_playlists([ - Playlist(name='Top 100 tracks', uri='dummy:/1'), + Playlist(name='Top 100 tracks', uri='dummy:/1', last_modified=123), ]) response1 = self.send_request('lsinfo "/"') From a72c9c88c9e4c71382a96d320cea9e6ca9036d71 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 23:51:14 +0200 Subject: [PATCH 116/318] http: Handle getting correct IOLoop when running on tornado < 3.0 --- docs/changelog.rst | 8 ++++++++ mopidy/http/handlers.py | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3b0336a0..123ca456 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,14 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.2 (unreleased) +=================== + +Bug fix release. + +- HTTP: Make event broadcasts work with Tornado 2.3, the previous threading fix + broke this. + v1.0.1 (2015-04-23) =================== diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 4f4b5988..e2faa944 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -88,9 +88,13 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): @classmethod def broadcast(cls, msg): + if hasattr(tornado.ioloop.IOLoop, 'current'): + loop = tornado.ioloop.IOLoop.current() + else: + loop = tornado.ioloop.IOLoop.instance() # Fallback for 2.3 + # This can be called from outside the Tornado ioloop, so we need to # safely cross the thread boundary by adding a callback to the loop. - loop = tornado.ioloop.IOLoop.current() for client in cls.clients: # One callback per client to keep time we hold up the loop short loop.add_callback(_send_broadcast, client, msg) From 7e59a5aecbb847a0eb269b8ccb71b196c8459a69 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 25 Apr 2015 00:06:53 +0200 Subject: [PATCH 117/318] models: Split models into smaller modules --- mopidy/models.py | 672 ------------------------------------ mopidy/models/__init__.py | 352 +++++++++++++++++++ mopidy/models/fields.py | 125 +++++++ mopidy/models/immutable.py | 160 +++++++++ mopidy/models/serialize.py | 46 +++ tests/models/test_fields.py | 3 +- 6 files changed, 685 insertions(+), 673 deletions(-) delete mode 100644 mopidy/models.py create mode 100644 mopidy/models/__init__.py create mode 100644 mopidy/models/fields.py create mode 100644 mopidy/models/immutable.py create mode 100644 mopidy/models/serialize.py diff --git a/mopidy/models.py b/mopidy/models.py deleted file mode 100644 index f4404fb8..00000000 --- a/mopidy/models.py +++ /dev/null @@ -1,672 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import copy -import inspect -import itertools -import json -import weakref - -from mopidy.utils import deprecation - -# TODO: split into base models, serialization and fields? - - -class Field(object): - - """ - Base field for use in :class:`ImmutableObject`. 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 - the object they are attached to allow it. - - Default values will be validated with the exception of :class:`None`. - - :param default: default value for field - :param type: if set the field value must be of this type - :param choices: if set the field value must be one of these - """ - - def __init__(self, default=None, type=None, choices=None): - self._name = None # Set by ImmutableObjectMeta - self._choices = choices - self._default = default - self._type = type - - if self._default is not None: - self.validate(self._default) - - def validate(self, value): - """Validate and possibly modify the field value before assignment""" - if self._type and not isinstance(value, self._type): - raise TypeError('Expected %s to be a %s, not %r' % - (self._name, self._type, value)) - if self._choices and value not in self._choices: - raise TypeError('Expected %s to be a one of %s, not %r' % - (self._name, self._choices, value)) - return value - - def __get__(self, instance, owner): - if not instance: - return self - return getattr(instance, '_' + self._name, self._default) - - def __set__(self, instance, value): - if value is not None: - value = self.validate(value) - - if value is None or value == self._default: - self.__delete__(instance) - else: - setattr(instance, '_' + self._name, value) - - def __delete__(self, instance): - if hasattr(instance, '_' + self._name): - delattr(instance, '_' + self._name) - - -class String(Field): - - """ - Specialized :class:`Field` which is wired up for bytes and unicode. - - :param default: default value for field - """ - - def __init__(self, default=None): - # TODO: normalize to unicode? - # TODO: only allow unicode? - # TODO: disallow empty strings? - super(String, self).__init__(type=basestring, default=default) - - -class Identifier(String): - def validate(self, value): - return intern(str(super(Identifier, self).validate(value))) - - -class Integer(Field): - - """ - :class:`Field` for storing integer numbers. - - :param default: default value for field - :param min: field value must be larger or equal to this value when set - :param max: field value must be smaller or equal to this value when set - """ - - def __init__(self, default=None, min=None, max=None): - self._min = min - self._max = max - super(Integer, self).__init__(type=(int, long), default=default) - - def validate(self, value): - value = super(Integer, self).validate(value) - if self._min is not None and value < self._min: - raise ValueError('Expected %s to be at least %d, not %d' % - (self._name, self._min, value)) - if self._max is not None and value > self._max: - raise ValueError('Expected %s to be at most %d, not %d' % - (self._name, self._max, value)) - return value - - -class Collection(Field): - - """ - :class:`Field` for storing collections of a given type. - - :param type: all items stored in the collection must be of this type - :param container: the type to store the items in - """ - - def __init__(self, type, container=tuple): - super(Collection, self).__init__(type=type, default=container()) - - def validate(self, value): - if isinstance(value, basestring): - raise TypeError('Expected %s to be a collection of %s, not %r' - % (self._name, self._type.__name__, value)) - for v in value: - if not isinstance(v, self._type): - raise TypeError('Expected %s to be a collection of %s, not %r' - % (self._name, self._type.__name__, value)) - return self._default.__class__(value) or None - - -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. - - 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. - - :param kwargs: kwargs to set as fields on the object - :type kwargs: any - """ - - __metaclass__ = ImmutableObjectMeta - - def __init__(self, *args, **kwargs): - for key, value in kwargs.items(): - if key not in self._fields: - raise TypeError( - '__init__() got an unexpected keyword argument "%s"' % - key) - super(ImmutableObject, self).__setattr__(key, value) - - def __setattr__(self, name, value): - if name in self.__slots__: - return super(ImmutableObject, self).__setattr__(name, value) - 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.') - - def _items(self): - for field, key in self._fields.items(): - if hasattr(self, key): - yield field, getattr(self, key) - - def __repr__(self): - kwarg_pairs = [] - for key, value in sorted(self._items()): - if isinstance(value, (frozenset, tuple)): - if not value: - continue - value = list(value) - kwarg_pairs.append('%s=%s' % (key, repr(value))) - return '%(classname)s(%(kwargs)s)' % { - 'classname': self.__class__.__name__, - 'kwargs': ', '.join(kwarg_pairs), - } - - 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 - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - return all(a == b for a, b in itertools.izip_longest( - self._items(), other._items(), fillvalue=object())) - - def __ne__(self, other): - return not self.__eq__(other) - - def copy(self, **values): - """ - .. deprecated:: 1.1 - Use :meth:`replace` instead. Note that we no longer return copies. - """ - 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) - - Note that internally we memoize heavily to keep memory usage down given - our overly repetitive data structures. So you might get an existing - instance if it contains the same values. - - :param kwargs: kwargs to set as fields on the object - :type kwargs: any - :rtype: instance of the model with replaced fields - """ - 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') - 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 - - -class ModelJSONEncoder(json.JSONEncoder): - - """ - Automatically serialize Mopidy models to JSON. - - Usage:: - - >>> import json - >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) - '{"a_track": {"__model__": "Track", "name": "name"}}' - - """ - - def default(self, obj): - if isinstance(obj, ImmutableObject): - return obj.serialize() - return json.JSONEncoder.default(self, obj) - - -def model_json_decoder(dct): - """ - Automatically deserialize Mopidy models from JSON. - - Usage:: - - >>> import json - >>> json.loads( - ... '{"a_track": {"__model__": "Track", "name": "name"}}', - ... object_hook=model_json_decoder) - {u'a_track': Track(artists=[], name=u'name')} - - """ - if '__model__' in dct: - # TODO: move models to a global constant once we split this module - models = {c.__name__: c for c in ImmutableObject.__subclasses__()} - model_name = dct.pop('__model__') - if model_name in models: - return models[model_name](**dct) - return dct - - -class Ref(ImmutableObject): - - """ - Model to represent URI references with a human friendly name and type - attached. This is intended for use a lightweight object "free" of metadata - that can be passed around instead of using full blown models. - - :param uri: object URI - :type uri: string - :param name: object name - :type name: string - :param type: object type - :type type: string - """ - - #: The object URI. Read-only. - uri = Identifier() - - #: The object name. Read-only. - name = String() - - #: Constant used for comparison with the :attr:`type` field. - ALBUM = 'album' - - #: Constant used for comparison with the :attr:`type` field. - ARTIST = 'artist' - - #: Constant used for comparison with the :attr:`type` field. - DIRECTORY = 'directory' - - #: Constant used for comparison with the :attr:`type` field. - PLAYLIST = 'playlist' - - #: Constant used for comparison with the :attr:`type` field. - TRACK = 'track' - - #: The object type, e.g. "artist", "album", "track", "playlist", - #: "directory". Read-only. - type = Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) - - @classmethod - def album(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" - kwargs['type'] = Ref.ALBUM - return cls(**kwargs) - - @classmethod - def artist(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`ARTIST`.""" - kwargs['type'] = Ref.ARTIST - return cls(**kwargs) - - @classmethod - def directory(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`DIRECTORY`.""" - kwargs['type'] = Ref.DIRECTORY - return cls(**kwargs) - - @classmethod - def playlist(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`PLAYLIST`.""" - kwargs['type'] = Ref.PLAYLIST - return cls(**kwargs) - - @classmethod - def track(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`TRACK`.""" - kwargs['type'] = Ref.TRACK - return cls(**kwargs) - - -class Image(ImmutableObject): - - """ - :param string uri: URI of the image - :param int width: Optional width of image or :class:`None` - :param int height: Optional height of image or :class:`None` - """ - - #: The image URI. Read-only. - uri = Identifier() - - #: Optional width of the image or :class:`None`. Read-only. - width = Integer(min=0) - - #: Optional height of the image or :class:`None`. Read-only. - height = Integer(min=0) - - -class Artist(ImmutableObject): - - """ - :param uri: artist URI - :type uri: string - :param name: artist name - :type name: string - :param musicbrainz_id: MusicBrainz ID - :type musicbrainz_id: string - """ - - #: The artist URI. Read-only. - uri = Identifier() - - #: The artist name. Read-only. - name = String() - - #: The MusicBrainz ID of the artist. Read-only. - musicbrainz_id = Identifier() - - -class Album(ImmutableObject): - - """ - :param uri: album URI - :type uri: string - :param name: album name - :type name: string - :param artists: album artists - :type artists: list of :class:`Artist` - :param num_tracks: number of tracks in album - :type num_tracks: integer or :class:`None` if unknown - :param num_discs: number of discs in album - :type num_discs: integer or :class:`None` if unknown - :param date: album release date (YYYY or YYYY-MM-DD) - :type date: string - :param musicbrainz_id: MusicBrainz ID - :type musicbrainz_id: string - :param images: album image URIs - :type images: list of strings - """ - - #: The album URI. Read-only. - uri = Identifier() - - #: The album name. Read-only. - name = String() - - #: A set of album artists. Read-only. - artists = Collection(type=Artist, container=frozenset) - - #: The number of tracks in the album. Read-only. - num_tracks = Integer(min=0) - - #: The number of discs in the album. Read-only. - num_discs = Integer(min=0) - - #: The album release date. Read-only. - date = String() # TODO: add date type - - #: The MusicBrainz ID of the album. Read-only. - musicbrainz_id = Identifier() - - #: The album image URIs. Read-only. - images = Collection(type=basestring, container=frozenset) - # XXX If we want to keep the order of images we shouldn't use frozenset() - # as it doesn't preserve order. I'm deferring this issue until we got - # actual usage of this field with more than one image. - - -class Track(ImmutableObject): - - """ - :param uri: track URI - :type uri: string - :param name: track name - :type name: string - :param artists: track artists - :type artists: list of :class:`Artist` - :param album: track album - :type album: :class:`Album` - :param composers: track composers - :type composers: string - :param performers: track performers - :type performers: string - :param genre: track genre - :type genre: string - :param track_no: track number in album - :type track_no: integer or :class:`None` if unknown - :param disc_no: disc number in album - :type disc_no: integer or :class:`None` if unknown - :param date: track release date (YYYY or YYYY-MM-DD) - :type date: string - :param length: track length in milliseconds - :type length: integer or :class:`None` if there is no duration - :param bitrate: bitrate in kbit/s - :type bitrate: integer - :param comment: track comment - :type comment: string - :param musicbrainz_id: MusicBrainz ID - :type musicbrainz_id: string - :param last_modified: Represents last modification time - :type last_modified: integer or :class:`None` if unknown - """ - - #: The track URI. Read-only. - uri = Identifier() - - #: The track name. Read-only. - name = String() - - #: A set of track artists. Read-only. - artists = Collection(type=Artist, container=frozenset) - - #: The track :class:`Album`. Read-only. - album = Field(type=Album) - - #: A set of track composers. Read-only. - composers = Collection(type=Artist, container=frozenset) - - #: A set of track performers`. Read-only. - performers = Collection(type=Artist, container=frozenset) - - #: The track genre. Read-only. - genre = String() - - #: The track number in the album. Read-only. - track_no = Integer(min=0) - - #: The disc number in the album. Read-only. - disc_no = Integer(min=0) - - #: The track release date. Read-only. - date = String() # TODO: add date type - - #: The track length in milliseconds. Read-only. - length = Integer(min=0) - - #: The track's bitrate in kbit/s. Read-only. - bitrate = Integer(min=0) - - #: The track comment. Read-only. - comment = String() - - #: The MusicBrainz ID of the track. Read-only. - musicbrainz_id = Identifier() - - #: Integer representing when the track was last modified. Exact meaning - #: depends on source of track. For local files this is the modification - #: time in milliseconds since Unix epoch. For other backends it could be an - #: equivalent timestamp or simply a version counter. - last_modified = Integer(min=0) - - -class TlTrack(ImmutableObject): - - """ - A tracklist track. Wraps a regular track and it's tracklist ID. - - The use of :class:`TlTrack` allows the same track to appear multiple times - in the tracklist. - - This class also accepts it's parameters as positional arguments. Both - arguments must be provided, and they must appear in the order they are - listed here. - - This class also supports iteration, so your extract its values like this:: - - (tlid, track) = tl_track - - :param tlid: tracklist ID - :type tlid: int - :param track: the track - :type track: :class:`Track` - """ - - #: The tracklist ID. Read-only. - tlid = Integer(min=0) - - #: The track. Read-only. - track = Field(type=Track) - - def __init__(self, *args, **kwargs): - if len(args) == 2 and len(kwargs) == 0: - kwargs['tlid'] = args[0] - kwargs['track'] = args[1] - args = [] - super(TlTrack, self).__init__(*args, **kwargs) - - def __iter__(self): - return iter([self.tlid, self.track]) - - -class Playlist(ImmutableObject): - - """ - :param uri: playlist URI - :type uri: string - :param name: playlist name - :type name: string - :param tracks: playlist's tracks - :type tracks: list of :class:`Track` elements - :param last_modified: - playlist's modification time in milliseconds since Unix epoch - :type last_modified: int - """ - - #: The playlist URI. Read-only. - uri = Identifier() - - #: The playlist name. Read-only. - name = String() - - #: The playlist's tracks. Read-only. - tracks = Collection(type=Track, container=tuple) - - #: The playlist modification time in milliseconds since Unix epoch. - #: Read-only. - #: - #: Integer, or :class:`None` if unknown. - last_modified = Integer(min=0) - - # TODO: def insert(self, pos, track): ... ? - - @property - def length(self): - """The number of tracks in the playlist. Read-only.""" - return len(self.tracks) - - -class SearchResult(ImmutableObject): - - """ - :param uri: search result URI - :type uri: string - :param tracks: matching tracks - :type tracks: list of :class:`Track` elements - :param artists: matching artists - :type artists: list of :class:`Artist` elements - :param albums: matching albums - :type albums: list of :class:`Album` elements - """ - - # The search result URI. Read-only. - uri = Identifier() - - # The tracks matching the search query. Read-only. - tracks = Collection(type=Track, container=tuple) - - # The artists matching the search query. Read-only. - artists = Collection(type=Artist, container=tuple) - - # The albums matching the search query. Read-only. - albums = Collection(type=Album, container=tuple) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py new file mode 100644 index 00000000..0cdfb37f --- /dev/null +++ b/mopidy/models/__init__.py @@ -0,0 +1,352 @@ +from __future__ import absolute_import, unicode_literals + +from mopidy.models import fields +from mopidy.models.immutable import ImmutableObject + +# TODO: remove the following "exports" once users have been migrated +from mopidy.models.serialize import model_json_decoder, ModelJSONEncoder # noqa + + +class Ref(ImmutableObject): + + """ + Model to represent URI references with a human friendly name and type + attached. This is intended for use a lightweight object "free" of metadata + that can be passed around instead of using full blown models. + + :param uri: object URI + :type uri: string + :param name: object name + :type name: string + :param type: object type + :type type: string + """ + + #: The object URI. Read-only. + uri = fields.Identifier() + + #: The object name. Read-only. + name = fields.String() + + #: Constant used for comparison with the :attr:`type` field. + ALBUM = 'album' + + #: Constant used for comparison with the :attr:`type` field. + ARTIST = 'artist' + + #: Constant used for comparison with the :attr:`type` field. + DIRECTORY = 'directory' + + #: Constant used for comparison with the :attr:`type` field. + PLAYLIST = 'playlist' + + #: Constant used for comparison with the :attr:`type` field. + TRACK = 'track' + + #: The object type, e.g. "artist", "album", "track", "playlist", + #: "directory". Read-only. + type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) + + @classmethod + def album(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" + kwargs['type'] = Ref.ALBUM + return cls(**kwargs) + + @classmethod + def artist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ARTIST`.""" + kwargs['type'] = Ref.ARTIST + return cls(**kwargs) + + @classmethod + def directory(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`DIRECTORY`.""" + kwargs['type'] = Ref.DIRECTORY + return cls(**kwargs) + + @classmethod + def playlist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`PLAYLIST`.""" + kwargs['type'] = Ref.PLAYLIST + return cls(**kwargs) + + @classmethod + def track(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`TRACK`.""" + kwargs['type'] = Ref.TRACK + return cls(**kwargs) + + +class Image(ImmutableObject): + + """ + :param string uri: URI of the image + :param int width: Optional width of image or :class:`None` + :param int height: Optional height of image or :class:`None` + """ + + #: The image URI. Read-only. + uri = fields.Identifier() + + #: Optional width of the image or :class:`None`. Read-only. + width = fields.Integer(min=0) + + #: Optional height of the image or :class:`None`. Read-only. + height = fields.Integer(min=0) + + +class Artist(ImmutableObject): + + """ + :param uri: artist URI + :type uri: string + :param name: artist name + :type name: string + :param musicbrainz_id: MusicBrainz ID + :type musicbrainz_id: string + """ + + #: The artist URI. Read-only. + uri = fields.Identifier() + + #: The artist name. Read-only. + name = fields.String() + + #: The MusicBrainz ID of the artist. Read-only. + musicbrainz_id = fields.Identifier() + + +class Album(ImmutableObject): + + """ + :param uri: album URI + :type uri: string + :param name: album name + :type name: string + :param artists: album artists + :type artists: list of :class:`Artist` + :param num_tracks: number of tracks in album + :type num_tracks: integer or :class:`None` if unknown + :param num_discs: number of discs in album + :type num_discs: integer or :class:`None` if unknown + :param date: album release date (YYYY or YYYY-MM-DD) + :type date: string + :param musicbrainz_id: MusicBrainz ID + :type musicbrainz_id: string + :param images: album image URIs + :type images: list of strings + """ + + #: The album URI. Read-only. + uri = fields.Identifier() + + #: The album name. Read-only. + name = fields.String() + + #: A set of album artists. Read-only. + artists = fields.Collection(type=Artist, container=frozenset) + + #: The number of tracks in the album. Read-only. + num_tracks = fields.Integer(min=0) + + #: The number of discs in the album. Read-only. + num_discs = fields.Integer(min=0) + + #: The album release date. Read-only. + date = fields.String() # TODO: add date type + + #: The MusicBrainz ID of the album. Read-only. + musicbrainz_id = fields.Identifier() + + #: The album image URIs. Read-only. + images = fields.Collection(type=basestring, container=frozenset) + # XXX If we want to keep the order of images we shouldn't use frozenset() + # as it doesn't preserve order. I'm deferring this issue until we got + # actual usage of this field with more than one image. + + +class Track(ImmutableObject): + + """ + :param uri: track URI + :type uri: string + :param name: track name + :type name: string + :param artists: track artists + :type artists: list of :class:`Artist` + :param album: track album + :type album: :class:`Album` + :param composers: track composers + :type composers: string + :param performers: track performers + :type performers: string + :param genre: track genre + :type genre: string + :param track_no: track number in album + :type track_no: integer or :class:`None` if unknown + :param disc_no: disc number in album + :type disc_no: integer or :class:`None` if unknown + :param date: track release date (YYYY or YYYY-MM-DD) + :type date: string + :param length: track length in milliseconds + :type length: integer or :class:`None` if there is no duration + :param bitrate: bitrate in kbit/s + :type bitrate: integer + :param comment: track comment + :type comment: string + :param musicbrainz_id: MusicBrainz ID + :type musicbrainz_id: string + :param last_modified: Represents last modification time + :type last_modified: integer or :class:`None` if unknown + """ + + #: The track URI. Read-only. + uri = fields.Identifier() + + #: The track name. Read-only. + name = fields.String() + + #: A set of track artists. Read-only. + artists = fields.Collection(type=Artist, container=frozenset) + + #: The track :class:`Album`. Read-only. + album = fields.Field(type=Album) + + #: A set of track composers. Read-only. + composers = fields.Collection(type=Artist, container=frozenset) + + #: A set of track performers`. Read-only. + performers = fields.Collection(type=Artist, container=frozenset) + + #: The track genre. Read-only. + genre = fields.String() + + #: The track number in the album. Read-only. + track_no = fields.Integer(min=0) + + #: The disc number in the album. Read-only. + disc_no = fields.Integer(min=0) + + #: The track release date. Read-only. + date = fields.String() # TODO: add date type + + #: The track length in milliseconds. Read-only. + length = fields.Integer(min=0) + + #: The track's bitrate in kbit/s. Read-only. + bitrate = fields.Integer(min=0) + + #: The track comment. Read-only. + comment = fields.String() + + #: The MusicBrainz ID of the track. Read-only. + musicbrainz_id = fields.Identifier() + + #: Integer representing when the track was last modified. Exact meaning + #: depends on source of track. For local files this is the modification + #: time in milliseconds since Unix epoch. For other backends it could be an + #: equivalent timestamp or simply a version counter. + last_modified = fields.Integer(min=0) + + +class TlTrack(ImmutableObject): + + """ + A tracklist track. Wraps a regular track and it's tracklist ID. + + The use of :class:`TlTrack` allows the same track to appear multiple times + in the tracklist. + + This class also accepts it's parameters as positional arguments. Both + arguments must be provided, and they must appear in the order they are + listed here. + + This class also supports iteration, so your extract its values like this:: + + (tlid, track) = tl_track + + :param tlid: tracklist ID + :type tlid: int + :param track: the track + :type track: :class:`Track` + """ + + #: The tracklist ID. Read-only. + tlid = fields.Integer(min=0) + + #: The track. Read-only. + track = fields.Field(type=Track) + + def __init__(self, *args, **kwargs): + if len(args) == 2 and len(kwargs) == 0: + kwargs['tlid'] = args[0] + kwargs['track'] = args[1] + args = [] + super(TlTrack, self).__init__(*args, **kwargs) + + def __iter__(self): + return iter([self.tlid, self.track]) + + +class Playlist(ImmutableObject): + + """ + :param uri: playlist URI + :type uri: string + :param name: playlist name + :type name: string + :param tracks: playlist's tracks + :type tracks: list of :class:`Track` elements + :param last_modified: + playlist's modification time in milliseconds since Unix epoch + :type last_modified: int + """ + + #: The playlist URI. Read-only. + uri = fields.Identifier() + + #: The playlist name. Read-only. + name = fields.String() + + #: The playlist's tracks. Read-only. + tracks = fields.Collection(type=Track, container=tuple) + + #: The playlist modification time in milliseconds since Unix epoch. + #: Read-only. + #: + #: Integer, or :class:`None` if unknown. + last_modified = fields.Integer(min=0) + + # TODO: def insert(self, pos, track): ... ? + + @property + def length(self): + """The number of tracks in the playlist. Read-only.""" + return len(self.tracks) + + +class SearchResult(ImmutableObject): + + """ + :param uri: search result URI + :type uri: string + :param tracks: matching tracks + :type tracks: list of :class:`Track` elements + :param artists: matching artists + :type artists: list of :class:`Artist` elements + :param albums: matching albums + :type albums: list of :class:`Album` elements + """ + + # The search result URI. Read-only. + uri = fields.Identifier() + + # The tracks matching the search query. Read-only. + tracks = fields.Collection(type=Track, container=tuple) + + # The artists matching the search query. Read-only. + artists = fields.Collection(type=Artist, container=tuple) + + # The albums matching the search query. Read-only. + albums = fields.Collection(type=Album, container=tuple) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py new file mode 100644 index 00000000..3819c1c4 --- /dev/null +++ b/mopidy/models/fields.py @@ -0,0 +1,125 @@ +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. + + For simplicity fields use the Python descriptor protocol to store the + values in the instance dictionary. Also note that fields are mutable if + the object they are attached to allow it. + + Default values will be validated with the exception of :class:`None`. + + :param default: default value for field + :param type: if set the field value must be of this type + :param choices: if set the field value must be one of these + """ + + def __init__(self, default=None, type=None, choices=None): + self._name = None # Set by ImmutableObjectMeta + self._choices = choices + self._default = default + self._type = type + + if self._default is not None: + self.validate(self._default) + + def validate(self, value): + """Validate and possibly modify the field value before assignment""" + if self._type and not isinstance(value, self._type): + raise TypeError('Expected %s to be a %s, not %r' % + (self._name, self._type, value)) + if self._choices and value not in self._choices: + raise TypeError('Expected %s to be a one of %s, not %r' % + (self._name, self._choices, value)) + return value + + def __get__(self, instance, owner): + if not instance: + return self + return getattr(instance, '_' + self._name, self._default) + + def __set__(self, instance, value): + if value is not None: + value = self.validate(value) + + if value is None or value == self._default: + self.__delete__(instance) + else: + setattr(instance, '_' + self._name, value) + + def __delete__(self, instance): + if hasattr(instance, '_' + self._name): + delattr(instance, '_' + self._name) + + +class String(Field): + + """ + Specialized :class:`Field` which is wired up for bytes and unicode. + + :param default: default value for field + """ + + def __init__(self, default=None): + # TODO: normalize to unicode? + # TODO: only allow unicode? + # TODO: disallow empty strings? + super(String, self).__init__(type=basestring, default=default) + + +class Identifier(String): + def validate(self, value): + return intern(str(super(Identifier, self).validate(value))) + + +class Integer(Field): + + """ + :class:`Field` for storing integer numbers. + + :param default: default value for field + :param min: field value must be larger or equal to this value when set + :param max: field value must be smaller or equal to this value when set + """ + + def __init__(self, default=None, min=None, max=None): + self._min = min + self._max = max + super(Integer, self).__init__(type=(int, long), default=default) + + def validate(self, value): + value = super(Integer, self).validate(value) + if self._min is not None and value < self._min: + raise ValueError('Expected %s to be at least %d, not %d' % + (self._name, self._min, value)) + if self._max is not None and value > self._max: + raise ValueError('Expected %s to be at most %d, not %d' % + (self._name, self._max, value)) + return value + + +class Collection(Field): + + """ + :class:`Field` for storing collections of a given type. + + :param type: all items stored in the collection must be of this type + :param container: the type to store the items in + """ + + def __init__(self, type, container=tuple): + super(Collection, self).__init__(type=type, default=container()) + + def validate(self, value): + if isinstance(value, basestring): + raise TypeError('Expected %s to be a collection of %s, not %r' + % (self._name, self._type.__name__, value)) + for v in value: + if not isinstance(v, self._type): + raise TypeError('Expected %s to be a collection of %s, not %r' + % (self._name, self._type.__name__, value)) + return self._default.__class__(value) or None diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py new file mode 100644 index 00000000..2b7bfa5b --- /dev/null +++ b/mopidy/models/immutable.py @@ -0,0 +1,160 @@ +from __future__ import absolute_import, unicode_literals + +import copy +import inspect +import itertools +import weakref + +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. + + 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. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + """ + + __metaclass__ = ImmutableObjectMeta + + def __init__(self, *args, **kwargs): + for key, value in kwargs.items(): + if key not in self._fields: + raise TypeError( + '__init__() got an unexpected keyword argument "%s"' % + key) + super(ImmutableObject, self).__setattr__(key, value) + + def __setattr__(self, name, value): + if name in self.__slots__: + return super(ImmutableObject, self).__setattr__(name, value) + 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.') + + def _items(self): + for field, key in self._fields.items(): + if hasattr(self, key): + yield field, getattr(self, key) + + def __repr__(self): + kwarg_pairs = [] + for key, value in sorted(self._items()): + if isinstance(value, (frozenset, tuple)): + if not value: + continue + value = list(value) + kwarg_pairs.append('%s=%s' % (key, repr(value))) + return '%(classname)s(%(kwargs)s)' % { + 'classname': self.__class__.__name__, + 'kwargs': ', '.join(kwarg_pairs), + } + + 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 + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return all(a == b for a, b in itertools.izip_longest( + self._items(), other._items(), fillvalue=object())) + + def __ne__(self, other): + return not self.__eq__(other) + + def copy(self, **values): + """ + .. deprecated:: 1.1 + Use :meth:`replace` instead. Note that we no longer return copies. + """ + 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) + + Note that internally we memoize heavily to keep memory usage down given + our overly repetitive data structures. So you might get an existing + instance if it contains the same values. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + :rtype: instance of the model with replaced fields + """ + 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') + 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 diff --git a/mopidy/models/serialize.py b/mopidy/models/serialize.py new file mode 100644 index 00000000..0f55c659 --- /dev/null +++ b/mopidy/models/serialize.py @@ -0,0 +1,46 @@ +from __future__ import absolute_import, unicode_literals + +import json + +from mopidy.models.immutable import ImmutableObject + + +class ModelJSONEncoder(json.JSONEncoder): + + """ + Automatically serialize Mopidy models to JSON. + + Usage:: + + >>> import json + >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) + '{"a_track": {"__model__": "Track", "name": "name"}}' + + """ + + def default(self, obj): + if isinstance(obj, ImmutableObject): + return obj.serialize() + return json.JSONEncoder.default(self, obj) + + +def model_json_decoder(dct): + """ + Automatically deserialize Mopidy models from JSON. + + Usage:: + + >>> import json + >>> json.loads( + ... '{"a_track": {"__model__": "Track", "name": "name"}}', + ... object_hook=model_json_decoder) + {u'a_track': Track(artists=[], name=u'name')} + + """ + if '__model__' in dct: + # TODO: move models to a global constant once we split this module + models = {c.__name__: c for c in ImmutableObject.__subclasses__()} + model_name = dct.pop('__model__') + if model_name in models: + return models[model_name](**dct) + return dct diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index 1bf46b7f..6ef10f18 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -2,7 +2,8 @@ from __future__ import absolute_import, unicode_literals import unittest -from mopidy.models import * # noqa: F403 +from mopidy.models.fields import * # noqa: F403 +from mopidy.models.immutable import ImmutableObjectMeta def create_instance(field): From 1d5abe44a6355150d0a5c02ec97f07346ba0ddc9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 25 Apr 2015 00:15:03 +0200 Subject: [PATCH 118/318] models: Add Uri and Date field "placeholders" --- mopidy/models/__init__.py | 18 +++++++++--------- mopidy/models/fields.py | 8 ++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 0cdfb37f..171d9df2 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -23,7 +23,7 @@ class Ref(ImmutableObject): """ #: The object URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The object name. Read-only. name = fields.String() @@ -87,7 +87,7 @@ class Image(ImmutableObject): """ #: The image URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: Optional width of the image or :class:`None`. Read-only. width = fields.Integer(min=0) @@ -108,7 +108,7 @@ class Artist(ImmutableObject): """ #: The artist URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The artist name. Read-only. name = fields.String() @@ -139,7 +139,7 @@ class Album(ImmutableObject): """ #: The album URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The album name. Read-only. name = fields.String() @@ -154,7 +154,7 @@ class Album(ImmutableObject): num_discs = fields.Integer(min=0) #: The album release date. Read-only. - date = fields.String() # TODO: add date type + date = fields.Date() #: The MusicBrainz ID of the album. Read-only. musicbrainz_id = fields.Identifier() @@ -202,7 +202,7 @@ class Track(ImmutableObject): """ #: The track URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The track name. Read-only. name = fields.String() @@ -229,7 +229,7 @@ class Track(ImmutableObject): disc_no = fields.Integer(min=0) #: The track release date. Read-only. - date = fields.String() # TODO: add date type + date = fields.Date() #: The track length in milliseconds. Read-only. length = fields.Integer(min=0) @@ -304,7 +304,7 @@ class Playlist(ImmutableObject): """ #: The playlist URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The playlist name. Read-only. name = fields.String() @@ -340,7 +340,7 @@ class SearchResult(ImmutableObject): """ # The search result URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() # The tracks matching the search query. Read-only. tracks = fields.Collection(type=Track, container=tuple) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index 3819c1c4..250e4758 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -71,11 +71,19 @@ class String(Field): super(String, self).__init__(type=basestring, default=default) +class Date(String): + pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using stftime. + + class Identifier(String): def validate(self, value): return intern(str(super(Identifier, self).validate(value))) +class Uri(Identifier): + pass # TODO: validate URIs? + + class Integer(Field): """ From 30f56abc6682a3f87c193763a349fc1a041f4f96 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 25 Apr 2015 00:56:11 +0200 Subject: [PATCH 119/318] core: Add "one-of" validation for number of kwargs --- mopidy/core/library.py | 7 +------ mopidy/core/playback.py | 5 ++++- mopidy/core/tracklist.py | 7 +++---- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ca0a159d..0f4ebac1 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -189,14 +189,9 @@ class LibraryController(object): .. deprecated:: 1.0 The ``uri`` argument. Use ``uris`` instead. """ - none_set = uri is None and uris is None - both_set = uri is not None and uris is not None - - if none_set or both_set: + if sum(o is not None for o in [uri, uris]) != 1: raise ValueError("One of 'uri' or 'uris' must be set") - # TODO: validation.one_of(*args)? - uris is None or validation.check_uris(uris) uri is None or validation.check_uri(uri) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 2b1bbbc3..3605db0f 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -277,9 +277,12 @@ class PlaybackController(object): :param tlid: TLID of the track to play :type tlid: :class:`int` or :class:`None` """ + if sum(o is not None for o in [tl_track, tlid]) > 1: + raise ValueError('At most one of "tl_track" and "tlid" may be set') + tl_track is None or validation.check_instance(tl_track, models.TlTrack) tlid is None or validation.check_integer(tlid, min=0) - # TODO: check one of or none for args + if tl_track: deprecation.warn('core.playback.play:tl_track_kwarg', pending=True) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index dfcd503a..8fe3c612 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -406,10 +406,9 @@ class TracklistController(object): .. deprecated:: 1.0 The ``tracks`` and ``uri`` arguments. Use ``uris``. """ - assert tracks is not None or uri is not None or uris is not None, \ - 'tracks, uri or uris must be provided' - # TODO: check that only one of tracks uri and uris is set... - # TODO: can at_position be negative? + if sum(o is not None for o in [tracks, uri, uris]) != 1: + raise ValueError( + 'Exactly one of tracks, uri or uris must be provided') tracks is None or validation.check_instances(tracks, Track) uri is None or validation.check_uri(uri) From ef6b5dd51c182c6615516b01e44b2599a77ca326 Mon Sep 17 00:00:00 2001 From: Camilo Nova Date: Fri, 24 Apr 2015 18:19:59 -0500 Subject: [PATCH 120/318] Added Grooveshark backend --- docs/ext/backends.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index d6ed65cd..1ab00005 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -66,6 +66,15 @@ Provides a backend for playing music from Digital Media Servers using the `dLeyna `_ D-Bus interface. +Mopidy-Grooveshark +================== + +https://github.com/camilonova/mopidy-grooveshark + +Provides a backend for playing music from `Grooveshark +`_. + + Mopidy-GMusic ============= From b80361ccb2fd2bb3e1f6b3132b2e0d848d54c6d9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Apr 2015 23:07:10 +0200 Subject: [PATCH 121/318] audio: Increase per tee branch buffer size. Fixes #1147 --- docs/changelog.rst | 4 ++++ mopidy/audio/actor.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 123ca456..7dd2101a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,10 @@ Bug fix release. - HTTP: Make event broadcasts work with Tornado 2.3, the previous threading fix broke this. +- 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`) + v1.0.1 (2015-04-23) =================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index e0a7892a..3198c006 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -164,7 +164,7 @@ class _Outputs(gst.Bin): # 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-buffers', 5) + queue.set_property('max-size-buffers', 15) self.add(element) self.add(queue) queue.link(element) From e53bf561155b0aefb945bc00cc0060285f2850ca Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Apr 2015 23:15:03 +0200 Subject: [PATCH 122/318] audio: Make sure software mixer emits mute events. Turns out that gobject.GObject.set_property does not have a return value. --- docs/changelog.rst | 3 +++ mopidy/audio/actor.py | 8 +++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7dd2101a..e10165ac 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -16,6 +16,9 @@ Bug fix release. out this can cause distortions in certain cases. Give this an other go with a more generous buffer size. (Fixes: :issue:`1147`) +- Audio: Make sure mute events get emitted by software mixer. + (Fixes: :issue:`1146`) + v1.0.1 (2015-04-23) =================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 3198c006..45ad73ff 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -194,16 +194,14 @@ class SoftwareMixer(object): def set_volume(self, volume): self._element.set_property('volume', volume / 100.0) - self._mixer.trigger_volume_changed(volume) + self._mixer.trigger_volume_changed(self.get_volume()) def get_mute(self): return self._element.get_property('mute') def set_mute(self, mute): - result = self._element.set_property('mute', bool(mute)) - if result: - self._mixer.trigger_mute_changed(bool(mute)) - return result + self._element.set_property('mute', bool(mute)) + self._mixer.trigger_mute_changed(self.get_mute()) class _Handler(object): From 651e89357f2b46285088d8bbfb47b4220d9124e5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 24 Apr 2015 19:26:02 +0200 Subject: [PATCH 123/318] tests: Fix IssueGH1120RegressionTest flakiness --- tests/mpd/protocol/test_regression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 60a71ee8..a31258ab 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -197,7 +197,7 @@ class IssueGH1120RegressionTest(protocol.BaseTestCase): 'dummy:/': [Ref.playlist(name='Top 100 tracks', uri='dummy:/1')], } self.backend.playlists.set_dummy_playlists([ - Playlist(name='Top 100 tracks', uri='dummy:/1'), + Playlist(name='Top 100 tracks', uri='dummy:/1', last_modified=123), ]) response1 = self.send_request('lsinfo "/"') From 5f420a1bffec58b4a8a78bc3a47487b681a5417b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Apr 2015 23:31:56 +0200 Subject: [PATCH 124/318] review: Address review comments for models split --- mopidy/models/__init__.py | 20 +++++++++++--------- mopidy/models/fields.py | 6 +++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 171d9df2..7cf8cc77 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -2,9 +2,11 @@ from __future__ import absolute_import, unicode_literals from mopidy.models import fields from mopidy.models.immutable import ImmutableObject +from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder -# TODO: remove the following "exports" once users have been migrated -from mopidy.models.serialize import model_json_decoder, ModelJSONEncoder # noqa +__all__ = [ + 'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack', + 'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder'] class Ref(ImmutableObject): @@ -23,7 +25,7 @@ class Ref(ImmutableObject): """ #: The object URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The object name. Read-only. name = fields.String() @@ -87,7 +89,7 @@ class Image(ImmutableObject): """ #: The image URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: Optional width of the image or :class:`None`. Read-only. width = fields.Integer(min=0) @@ -108,7 +110,7 @@ class Artist(ImmutableObject): """ #: The artist URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The artist name. Read-only. name = fields.String() @@ -139,7 +141,7 @@ class Album(ImmutableObject): """ #: The album URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The album name. Read-only. name = fields.String() @@ -202,7 +204,7 @@ class Track(ImmutableObject): """ #: The track URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The track name. Read-only. name = fields.String() @@ -304,7 +306,7 @@ class Playlist(ImmutableObject): """ #: The playlist URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The playlist name. Read-only. name = fields.String() @@ -340,7 +342,7 @@ class SearchResult(ImmutableObject): """ # The search result URI. Read-only. - uri = fields.Uri() + uri = fields.URI() # The tracks matching the search query. Read-only. tracks = fields.Collection(type=Track, container=tuple) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index 250e4758..23154df5 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -72,7 +72,7 @@ class String(Field): class Date(String): - pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using stftime. + pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using strptime. class Identifier(String): @@ -80,7 +80,7 @@ class Identifier(String): return intern(str(super(Identifier, self).validate(value))) -class Uri(Identifier): +class URI(Identifier): pass # TODO: validate URIs? @@ -92,7 +92,7 @@ class Integer(Field): :param default: default value for field :param min: field value must be larger or equal to this value when set :param max: field value must be smaller or equal to this value when set - """ + """ def __init__(self, default=None, min=None, max=None): self._min = min From 5f627984a336896ff1add1233c70ad628b8d04bb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Apr 2015 23:35:00 +0200 Subject: [PATCH 125/318] review: Address review comments for one-of-validation --- mopidy/core/library.py | 2 +- mopidy/core/tracklist.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 0f4ebac1..e6da95f1 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -190,7 +190,7 @@ class LibraryController(object): The ``uri`` argument. Use ``uris`` instead. """ if sum(o is not None for o in [uri, uris]) != 1: - raise ValueError("One of 'uri' or 'uris' must be set") + raise ValueError('Exactly one of "uri" or "uris" must be set') uris is None or validation.check_uris(uris) uri is None or validation.check_uri(uri) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 8fe3c612..692762ef 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -408,7 +408,7 @@ class TracklistController(object): """ if sum(o is not None for o in [tracks, uri, uris]) != 1: raise ValueError( - 'Exactly one of tracks, uri or uris must be provided') + 'Exactly one of "tracks", "uri" or "uris" must be set') tracks is None or validation.check_instances(tracks, Track) uri is None or validation.check_uri(uri) From 31a1cb91288f7ea54226b4a1f7990172fe155e34 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 27 Apr 2015 00:00:10 +0200 Subject: [PATCH 126/318] docs: Update changelog for v1.0.2 --- docs/changelog.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e10165ac..f4b796d7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,20 +4,21 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.2 (unreleased) + +v1.0.2 (2015-04-27) =================== Bug fix release. -- HTTP: Make event broadcasts work with Tornado 2.3, the previous threading fix - broke this. +- HTTP: Make event broadcasts work with Tornado 2.3 again. The threading fix + in v1.0.1 broke this. - 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`) + a more generous buffer size. (Fixes: :issue:`1147`, PR: :issue:`1152`) - Audio: Make sure mute events get emitted by software mixer. - (Fixes: :issue:`1146`) + (Fixes: :issue:`1146`, PR: :issue:`1152`) v1.0.1 (2015-04-23) From 21289f8fe5672bfbe70d481f17ae1d4b646b75f0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 27 Apr 2015 00:02:12 +0200 Subject: [PATCH 127/318] Bump version to 1.0.2 --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 8dff0012..cabe0012 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.1' +__version__ = '1.0.2' diff --git a/tests/test_version.py b/tests/test_version.py index 3d284121..6071e2cd 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -56,5 +56,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('0.19.3', '0.19.4') self.assertVersionLess('0.19.4', '0.19.5') self.assertVersionLess('0.19.5', '1.0.0') - self.assertVersionLess('1.0.0', __version__) - self.assertVersionLess(__version__, '1.0.2') + self.assertVersionLess('1.0.0', '1.0.1') + self.assertVersionLess('1.0.1', __version__) + self.assertVersionLess(__version__, '1.0.3') From 7af10c52d2cfb89f286f7ef0626ce8072015a0ca Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 27 Apr 2015 00:04:12 +0200 Subject: [PATCH 128/318] models: Remove TODO to make models we can serialize a global Turns out that module construction time is too early to run this code. --- mopidy/models/serialize.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/models/serialize.py b/mopidy/models/serialize.py index 0f55c659..1c438efb 100644 --- a/mopidy/models/serialize.py +++ b/mopidy/models/serialize.py @@ -38,7 +38,6 @@ def model_json_decoder(dct): """ if '__model__' in dct: - # TODO: move models to a global constant once we split this module models = {c.__name__: c for c in ImmutableObject.__subclasses__()} model_name = dct.pop('__model__') if model_name in models: From d5ef4aa7b80b713c9d625a5c6e3b05d6269dc616 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 27 Apr 2015 00:12:41 +0200 Subject: [PATCH 129/318] docs: jessie is the new Debian stable --- docs/installation/debian.rst | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index 4def3fbb..490aa6ac 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -18,12 +18,12 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See The packages should work with: - - Debian stable and testing, - - Raspbian stable and testing, + - Debian stable ("jessie") and testing ("stretch"), + - Raspbian stable ("jessie") and testing ("stretch"), - Ubuntu 14.04 LTS and later. - Some of the packages, including the core "mopidy" packages, does *not* work - on Ubuntu 12.04 LTS. + Some of the packages *does not* work with Ubuntu 12.04 LTS or Debian 7 + "wheezy". This is just what we currently support, not a promise to continue to support the same in the future. We *will* drop support for older @@ -47,6 +47,13 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/mopidy.list + .. note:: + + If you're still running Debian 7 "wheezy" or Raspbian "wheezy", you + should edit :file:`/etc/apt/sources.list.d/mopidy.list` and replace + "stable" with "wheezy". This will give you the latest set of packages + that is compatible with Debian "wheezy". + #. Install Mopidy and all dependencies:: sudo apt-get update From 6a2b9f9896ca0c9e976d4835cd6fa0227abe270a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 27 Apr 2015 15:34:25 +0200 Subject: [PATCH 130/318] docs: Make the build reproducible By setting today's date to the current year, the manpage output doesn't vary with the day the manpage was built. Having reproducible builds is a goal for Debian stretch. --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index fa75dd79..ec74fcbe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -105,6 +105,9 @@ from mopidy.utils.versioning import get_version release = get_version() version = '.'.join(release.split('.')[:2]) +# To make the build reproducible, avoid using today's date in the manpages +today = '2015' + exclude_trees = ['_build'] pygments_style = 'sphinx' From fb22203ed380441e7662867a33d789975ee928d8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 27 Apr 2015 21:12:22 +0200 Subject: [PATCH 131/318] docs: Add getting help anchor --- docs/index.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index e9775030..3a2998d5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,6 +54,8 @@ extension. The cassettes have NFC tags used to select playlists from Spotify. To get started with Mopidy, start by reading :ref:`installation`. +.. _getting-help: + **Getting help** If you get stuck, you can get help at the `Mopidy discussion forum From f90e512ede519e7f1a1dce5168113a9dda6ba328 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 27 Apr 2015 21:12:22 +0200 Subject: [PATCH 132/318] docs: Add getting help anchor --- docs/index.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index e9775030..3a2998d5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,6 +54,8 @@ extension. The cassettes have NFC tags used to select playlists from Spotify. To get started with Mopidy, start by reading :ref:`installation`. +.. _getting-help: + **Getting help** If you get stuck, you can get help at the `Mopidy discussion forum From 022f84df26e2a5c50af5a03504139805c713a8db Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 27 Apr 2015 21:37:35 +0200 Subject: [PATCH 133/318] docs: Fix grammar --- docs/installation/debian.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index 490aa6ac..8cf08bca 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -22,7 +22,7 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See - Raspbian stable ("jessie") and testing ("stretch"), - Ubuntu 14.04 LTS and later. - Some of the packages *does not* work with Ubuntu 12.04 LTS or Debian 7 + Some of the packages *do not* work with Ubuntu 12.04 LTS or Debian 7 "wheezy". This is just what we currently support, not a promise to continue to From 83e54b090b28c97d08a714160722c6b9f0d9b9f3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 27 Apr 2015 22:52:12 +0200 Subject: [PATCH 134/318] http: Finally fix the old tornado broadcast bug. --- docs/changelog.rst | 7 +++++++ mopidy/http/handlers.py | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f4b796d7..369900e4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.3 (unreleased) +=================== + +- HTTP: An other follow-up to the pre 3.0 fixing. Since the tests aren't run + for 2.3 we didn't catch that our previous fix wasn't sufficient. + (Fixes: :issue:`1153`) + v1.0.2 (2015-04-27) =================== diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index e2faa944..6bbfcbab 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import functools import logging import os import socket @@ -91,13 +92,14 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): if hasattr(tornado.ioloop.IOLoop, 'current'): loop = tornado.ioloop.IOLoop.current() else: - loop = tornado.ioloop.IOLoop.instance() # Fallback for 2.3 + loop = tornado.ioloop.IOLoop.instance() # Fallback for pre 3.0 # This can be called from outside the Tornado ioloop, so we need to # safely cross the thread boundary by adding a callback to the loop. for client in cls.clients: # One callback per client to keep time we hold up the loop short - loop.add_callback(_send_broadcast, client, msg) + # NOTE: Pre 3.0 does not support *args or **kwargs... + loop.add_callback(functools.partial(_send_broadcast, client, msg)) def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) From 55e50ae5d2d9fdd7967f4dbb9e5e5d7168dc7adb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 27 Apr 2015 22:55:21 +0200 Subject: [PATCH 135/318] audio: Switch to time based buffering in tee branches --- docs/changelog.rst | 4 ++++ mopidy/audio/actor.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 369900e4..904e938c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,10 @@ v1.0.3 (unreleased) for 2.3 we didn't catch that our previous fix wasn't sufficient. (Fixes: :issue:`1153`) +- 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`) + v1.0.2 (2015-04-27) =================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 45ad73ff..7cca954a 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -164,7 +164,8 @@ class _Outputs(gst.Bin): # 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-buffers', 15) + queue.set_property('max-size-time', 100 * gst.MSECOND) + self.add(element) self.add(queue) queue.link(element) From d2bc7aa459bdf4d62f015989b9688f2c573e11cb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 27 Apr 2015 23:20:11 +0200 Subject: [PATCH 136/318] docs: Fix typo, add PR refs --- docs/changelog.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 904e938c..f9821559 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,16 +4,18 @@ Changelog This changelog is used to track all major changes to Mopidy. + v1.0.3 (unreleased) =================== -- HTTP: An other follow-up to the pre 3.0 fixing. Since the tests aren't run - for 2.3 we didn't catch that our previous fix wasn't sufficient. - (Fixes: :issue:`1153`) +- HTTP: Another follow-up to the pre 3.0 fixing. Since the tests aren't run for + 2.3 we didn't catch that our previous fix wasn't sufficient. (Fixes: + :issue:`1153`, PR: :issue:`1154`) - 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. (Fixes: :issue:`1147`, + PR: :issue:`1154`) v1.0.2 (2015-04-27) From f5a738132434b656f1de278979e2c4114f846102 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 27 Apr 2015 23:25:24 +0200 Subject: [PATCH 137/318] docs: Fix copy-paste error --- mopidy/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 63184853..2bbc1eea 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -395,7 +395,7 @@ class BackendListener(listener.Listener): Marker interface for recipients of events sent by the backend actors. Any Pykka actor that mixes in this class will receive calls to the methods - defined here when the corresponding events happen in the core actor. This + defined here when the corresponding events happen in a backend actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. From b8b811c81e1a72b6e5ef9ba2f63e45dcfd6f6389 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 27 Apr 2015 23:40:43 +0200 Subject: [PATCH 138/318] docs: Update changelog for v1.0.3 --- docs/changelog.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f9821559..d51b4bf1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,12 +5,14 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.3 (unreleased) +v1.0.3 (2015-04-28) =================== -- HTTP: Another follow-up to the pre 3.0 fixing. Since the tests aren't run for - 2.3 we didn't catch that our previous fix wasn't sufficient. (Fixes: - :issue:`1153`, PR: :issue:`1154`) +Bug fix release. + +- HTTP: Another follow-up to the Tornado <3.0 fixing. Since the tests aren't + run for Tornado 2.3 we didn't catch that our previous fix wasn't sufficient. + (Fixes: :issue:`1153`, PR: :issue:`1154`) - 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 From 9c2aabb899e799b8330dbd57665e41b617e2fe2f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 28 Apr 2015 00:00:15 +0200 Subject: [PATCH 139/318] Bump version to 1.0.3 --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index cabe0012..c2823270 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.2' +__version__ = '1.0.3' diff --git a/tests/test_version.py b/tests/test_version.py index 6071e2cd..1bdcfe01 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -57,5 +57,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('0.19.4', '0.19.5') self.assertVersionLess('0.19.5', '1.0.0') self.assertVersionLess('1.0.0', '1.0.1') - self.assertVersionLess('1.0.1', __version__) - self.assertVersionLess(__version__, '1.0.3') + self.assertVersionLess('1.0.1', '1.0.2') + self.assertVersionLess('1.0.2', __version__) + self.assertVersionLess(__version__, '1.0.4') From 8851fb151ce5d55d43752e725387b14916e120c1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 Apr 2015 22:58:15 +0200 Subject: [PATCH 140/318] models: Allow Ref.type to have any value This is to address a potential breakage brought up in #1150 as it turns out Mopidy-Podcast uses custom models and ref types. --- mopidy/models/__init__.py | 9 +++++---- tests/models/test_models.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 7cf8cc77..e4e8528a 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -30,6 +30,11 @@ class Ref(ImmutableObject): #: The object name. Read-only. name = fields.String() + #: The object type, e.g. "artist", "album", "track", "playlist", + #: "directory". Read-only. + type = fields.Identifier() # TODO: consider locking this down. + # type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) + #: Constant used for comparison with the :attr:`type` field. ALBUM = 'album' @@ -45,10 +50,6 @@ class Ref(ImmutableObject): #: Constant used for comparison with the :attr:`type` field. TRACK = 'track' - #: The object type, e.g. "artist", "album", "track", "playlist", - #: "directory". Read-only. - type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) - @classmethod def album(cls, **kwargs): """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" diff --git a/tests/models/test_models.py b/tests/models/test_models.py index c9c91ba1..bdfd1896 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -113,7 +113,7 @@ class RefTest(unittest.TestCase): def test_repr_without_results(self): self.assertEqual( - "Ref(name=u'foo', type=u'artist', uri='uri')", + "Ref(name=u'foo', type='artist', uri='uri')", repr(Ref(uri='uri', name='foo', type='artist'))) def test_serialize_without_results(self): From a48aadaaed5127aa08a762bb55b69b30905a6c51 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 Apr 2015 23:29:55 +0200 Subject: [PATCH 141/318] utils: Add basic format proxy helper --- mopidy/utils/http.py | 25 +++++++++++++++++++++++++ tests/utils/test_http.py | 20 ++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 mopidy/utils/http.py create mode 100644 tests/utils/test_http.py diff --git a/mopidy/utils/http.py b/mopidy/utils/http.py new file mode 100644 index 00000000..bc6d38f4 --- /dev/null +++ b/mopidy/utils/http.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + + +def format_proxy(proxy_config): + """Convert a Mopidy proxy config to the commonly used proxy string format. + + Outputs ``scheme://host:port``, ``scheme://user:pass@host:port`` or + :class:`None` depending on the proxy config provided. + """ + if not proxy_config.get('hostname'): + return None + + if proxy_config.get('username') and proxy_config.get('password'): + template = '{scheme}://{username}:{password}@{hostname}:{port}' + else: + template = '{scheme}://{hostname}:{port}' + + port = proxy_config.get('port', 80) + if port < 0: + port = 80 + + return template.format(scheme=proxy_config.get('scheme', 'http'), + username=proxy_config.get('username'), + password=proxy_config.get('password'), + hostname=proxy_config['hostname'], port=port) diff --git a/tests/utils/test_http.py b/tests/utils/test_http.py new file mode 100644 index 00000000..b63ebf31 --- /dev/null +++ b/tests/utils/test_http.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals + +import pytest + +from mopidy.utils import http + + +@pytest.mark.parametrize("config,expected", [ + ({}, None), + ({'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), + ({'scheme': 'https', 'hostname': 'proxy.lan'}, 'https://proxy.lan:80'), + ({'username': 'user', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), + ({'password': 'pass', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), + ({'hostname': 'proxy.lan', 'port': 8080}, 'http://proxy.lan:8080'), + ({'hostname': 'proxy.lan', 'port': -1}, 'http://proxy.lan:80'), + ({'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'}, + 'http://user:pass@proxy.lan:80'), +]) +def test_format_proxy(config, expected): + assert http.format_proxy(config) == expected From 5153d9e19fd890c174be51cc0ac6ae807f54f2b2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 Apr 2015 23:51:19 +0200 Subject: [PATCH 142/318] utils: Add format_user_agent helper --- mopidy/utils/http.py | 20 ++++++++++++++++++++ tests/utils/test_http.py | 11 +++++++++++ 2 files changed, 31 insertions(+) diff --git a/mopidy/utils/http.py b/mopidy/utils/http.py index bc6d38f4..f0c658e7 100644 --- a/mopidy/utils/http.py +++ b/mopidy/utils/http.py @@ -1,5 +1,11 @@ from __future__ import unicode_literals +import platform + +import mopidy + +"Helpers for configuring HTTP clients used in Mopidy extensions." + def format_proxy(proxy_config): """Convert a Mopidy proxy config to the commonly used proxy string format. @@ -23,3 +29,17 @@ def format_proxy(proxy_config): username=proxy_config.get('username'), password=proxy_config.get('password'), hostname=proxy_config['hostname'], port=port) + + +def format_user_agent(name=None): + """Construct a User-Agent suitable for use in client code. + + This will identify use by the provided name (which should be + ``dist_name/version``), Mopidy version and Python version. + """ + parts = ['Mopidy/%s' % (mopidy.__version__), + '%s/%s' % (platform.python_implementation(), + platform.python_version())] + if name: + parts.insert(0, name) + return ' '.join(parts) diff --git a/tests/utils/test_http.py b/tests/utils/test_http.py index b63ebf31..cf97e57c 100644 --- a/tests/utils/test_http.py +++ b/tests/utils/test_http.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import re + import pytest from mopidy.utils import http @@ -18,3 +20,12 @@ from mopidy.utils import http ]) def test_format_proxy(config, expected): assert http.format_proxy(config) == expected + + +@pytest.mark.parametrize("name,expected", [ + (None, r'^Mopidy/[^ ]+ CPython|/[^ ]+$'), + ('Foo', r'^Foo Mopidy/[^ ]+ CPython|/[^ ]+$'), + ('Foo/1.2.3', r'^Foo/1.2.3 Mopidy/[^ ]+ CPython|/[^ ]+$'), +]) +def test_format_user_agent(name, expected): + assert re.match(expected, http.format_user_agent(name)) From 8434896f2ddf298ca61f8fd0186c57461370d9f4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 Apr 2015 23:53:06 +0200 Subject: [PATCH 143/318] docs: Add http helpers to changelog --- docs/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index f1b11179..ef0c85b4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,7 @@ Core API ``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`, :issue:`1140`) + Models ------ @@ -38,6 +39,12 @@ Models reuse instances. For the test data set this was developed against, a library of ~14000 tracks, went from needing ~75MB to ~17MB. (Fixes: :issue:`348`) +Utils +----- + +- Add :func:`mopidy.utils.http.format_proxy` and + :func:`mopidy.utils.http.format_user_agent`. (Part of: :issue:`1156`) + Internal changes ---------------- From 8cf9da3d555b375ae72a782b094d6377cedd1a59 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 29 Apr 2015 00:27:56 +0200 Subject: [PATCH 144/318] utils: Fix corner case in format_proxy scheme handling --- mopidy/utils/http.py | 2 +- tests/utils/test_http.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/http.py b/mopidy/utils/http.py index f0c658e7..096a37a0 100644 --- a/mopidy/utils/http.py +++ b/mopidy/utils/http.py @@ -25,7 +25,7 @@ def format_proxy(proxy_config): if port < 0: port = 80 - return template.format(scheme=proxy_config.get('scheme', 'http'), + return template.format(scheme=proxy_config.get('scheme') or 'http', username=proxy_config.get('username'), password=proxy_config.get('password'), hostname=proxy_config['hostname'], port=port) diff --git a/tests/utils/test_http.py b/tests/utils/test_http.py index cf97e57c..dee09c3c 100644 --- a/tests/utils/test_http.py +++ b/tests/utils/test_http.py @@ -10,6 +10,7 @@ from mopidy.utils import http @pytest.mark.parametrize("config,expected", [ ({}, None), ({'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), + ({'scheme': None, 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'scheme': 'https', 'hostname': 'proxy.lan'}, 'https://proxy.lan:80'), ({'username': 'user', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'password': 'pass', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), From 9182a7870e51a28a3da22c7a9e82679d7d90c307 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 29 Apr 2015 00:38:06 +0200 Subject: [PATCH 145/318] utils: Support opting out of adding auth to proxy --- mopidy/utils/http.py | 15 +++++++++------ tests/utils/test_http.py | 6 ++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mopidy/utils/http.py b/mopidy/utils/http.py index 096a37a0..d0b3cb4c 100644 --- a/mopidy/utils/http.py +++ b/mopidy/utils/http.py @@ -7,24 +7,27 @@ import mopidy "Helpers for configuring HTTP clients used in Mopidy extensions." -def format_proxy(proxy_config): +def format_proxy(proxy_config, auth=True): """Convert a Mopidy proxy config to the commonly used proxy string format. Outputs ``scheme://host:port``, ``scheme://user:pass@host:port`` or :class:`None` depending on the proxy config provided. + + You can also opt out of getting the basic auth by setting ``auth`` to + :type:`False`. """ if not proxy_config.get('hostname'): return None - if proxy_config.get('username') and proxy_config.get('password'): - template = '{scheme}://{username}:{password}@{hostname}:{port}' - else: - template = '{scheme}://{hostname}:{port}' - port = proxy_config.get('port', 80) if port < 0: port = 80 + if proxy_config.get('username') and proxy_config.get('password') and auth: + template = '{scheme}://{username}:{password}@{hostname}:{port}' + else: + template = '{scheme}://{hostname}:{port}' + return template.format(scheme=proxy_config.get('scheme') or 'http', username=proxy_config.get('username'), password=proxy_config.get('password'), diff --git a/tests/utils/test_http.py b/tests/utils/test_http.py index dee09c3c..4553dc05 100644 --- a/tests/utils/test_http.py +++ b/tests/utils/test_http.py @@ -23,6 +23,12 @@ def test_format_proxy(config, expected): assert http.format_proxy(config) == expected +def test_format_proxy_without_auth(): + config = {'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'} + formated_proxy = http.format_proxy(config, auth=False) + assert formated_proxy == 'http://proxy.lan:80' + + @pytest.mark.parametrize("name,expected", [ (None, r'^Mopidy/[^ ]+ CPython|/[^ ]+$'), ('Foo', r'^Foo Mopidy/[^ ]+ CPython|/[^ ]+$'), From 924269616b3eb637eade8f8f5368be501cfe5fc7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 29 Apr 2015 00:38:23 +0200 Subject: [PATCH 146/318] audio: Use proxy helper in audio utils --- mopidy/audio/utils.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 1a8bf6a7..6266b64f 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -10,6 +10,7 @@ import gst # noqa from mopidy import compat from mopidy.models import Album, Artist, Track +from mopidy.utils import http logger = logging.getLogger(__name__) @@ -142,11 +143,7 @@ def setup_proxy(element, config): if not hasattr(element.props, 'proxy') or not config.get('hostname'): return - proxy = "%s://%s:%d" % (config.get('scheme', 'http'), - config.get('hostname'), - config.get('port', 80)) - - element.set_property('proxy', proxy) + element.set_property('proxy', http.format_proxy(config, auth=False)) element.set_property('proxy-id', config.get('username')) element.set_property('proxy-pw', config.get('password')) From 7938ef48eddb55bd0865bd0df48b5b9d99cc338a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 29 Apr 2015 21:27:57 +0200 Subject: [PATCH 147/318] audio: Stop tweaking tee queue sizes --- docs/changelog.rst | 14 ++++++++++++-- mopidy/audio/actor.py | 4 ---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d51b4bf1..f7b7beb4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,16 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.4 (unreleased) +=================== + +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) =================== @@ -16,7 +26,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`) @@ -30,7 +40,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`) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 7cca954a..fcd1e233 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -161,11 +161,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) From 94039e06dc95ba7f994c28363e6b4693fbe3505f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 29 Apr 2015 21:32:43 +0200 Subject: [PATCH 148/318] models: Make sure sub-classes can extend models --- mopidy/models/immutable.py | 3 ++- tests/models/test_models.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py index 2b7bfa5b..8e282c91 100644 --- a/mopidy/models/immutable.py +++ b/mopidy/models/immutable.py @@ -22,7 +22,8 @@ class ImmutableObjectMeta(type): attrs['_fields'] = fields attrs['_instances'] = weakref.WeakValueDictionary() - attrs['__slots__'] = ['_hash'] + fields.values() + attrs['__slots__'] = list(attrs.get('__slots__', [])) + attrs['__slots__'].extend(['_hash'] + fields.values()) for ancestor in [b for base in bases for b in inspect.getmro(base)]: if '__weakref__' in getattr(ancestor, '__slots__', []): diff --git a/tests/models/test_models.py b/tests/models/test_models.py index bdfd1896..f0f5ff6c 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -18,6 +18,14 @@ class InheritanceTest(unittest.TestCase): class Foo(Track): pass + def test_sub_class_can_have_its_own_slots(self): + + class Foo(Track): + __slots__ = ('_foo',) + + f = Foo() + f._foo = 123 + class CachingTest(unittest.TestCase): From b1475f7d060b60f0794dbc90f9aae10e0657ec04 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 30 Apr 2015 08:40:13 +0200 Subject: [PATCH 149/318] docs: Update changelog for v1.0.4 --- docs/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f7b7beb4..10c413ec 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,8 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.4 (unreleased) + +v1.0.4 (2015-04-30) =================== Bug fix release. From 2f96dacae82a4a903dfac761a8c9a4307812efee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 30 Apr 2015 08:41:03 +0200 Subject: [PATCH 150/318] Bump version to 1.0.4 --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index c2823270..0bc5410e 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.3' +__version__ = '1.0.4' diff --git a/tests/test_version.py b/tests/test_version.py index 1bdcfe01..37d0b459 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -58,5 +58,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('0.19.5', '1.0.0') self.assertVersionLess('1.0.0', '1.0.1') self.assertVersionLess('1.0.1', '1.0.2') - self.assertVersionLess('1.0.2', __version__) - self.assertVersionLess(__version__, '1.0.4') + self.assertVersionLess('1.0.2', '1.0.3') + self.assertVersionLess('1.0.3', __version__) + self.assertVersionLess(__version__, '1.0.5') From 624a69251febdb164d57082fdbec2a9d96b8aa9c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 3 May 2015 21:54:34 +0200 Subject: [PATCH 151/318] docs: Move Backend API docs to after Core API --- docs/api/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/index.rst b/docs/api/index.rst index 2402186e..3e008f00 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -15,8 +15,8 @@ API reference concepts models - backends core + backends audio mixer frontends From bb95dc3b9bf8d03b37142336de720582cd893c25 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 May 2015 22:58:43 +0200 Subject: [PATCH 152/318] models: Make sure parent fields are used by children. Without this change any sub-class would end up with an empty _fields and none of the actual fields would be writable even internally. --- mopidy/models/immutable.py | 4 +++- tests/models/test_models.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py index 8e282c91..485480a9 100644 --- a/mopidy/models/immutable.py +++ b/mopidy/models/immutable.py @@ -15,7 +15,9 @@ class ImmutableObjectMeta(type): def __new__(cls, name, bases, attrs): fields = {} - for key, value in attrs.items(): + 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 diff --git a/tests/models/test_models.py b/tests/models/test_models.py index f0f5ff6c..be82d3b2 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -19,6 +19,7 @@ class InheritanceTest(unittest.TestCase): 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',) @@ -26,6 +27,17 @@ class InheritanceTest(unittest.TestCase): 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): From 83c2bf9bcdcbea3e01843c8d444a34a37aecafff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 3 May 2015 23:32:35 +0200 Subject: [PATCH 153/318] docs: Fix Sphinx syntax error --- mopidy/core/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index e6da95f1..d9803d3f 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -106,7 +106,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. From a8b15e6af57258972fea085487838b66a8237e94 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 4 May 2015 08:37:09 +0200 Subject: [PATCH 154/318] mpd: Include more context in >=INFO logging --- mopidy/mpd/dispatcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 5abc1b4b..10a05fcb 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -167,7 +167,7 @@ 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) From 9b4d76866b60f31a37c5e4ad3d3492c2457e61e9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 4 May 2015 08:44:45 +0200 Subject: [PATCH 155/318] mpd: Fix flake8 warning --- mopidy/mpd/dispatcher.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 10a05fcb..a8e2c05c 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -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('MPD client used 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) From 07159f69c269ffadf0269d4cb16d649ec27092e4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 4 May 2015 21:37:17 +0200 Subject: [PATCH 156/318] models: Decouple fields tests from the model metaclass --- tests/models/test_fields.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index 6ef10f18..bf842fd5 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -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() From 7f6809aebb3eed98ea46d2fc1f33aeda8cd70711 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 4 May 2015 22:36:27 +0200 Subject: [PATCH 157/318] models: Explicitly define which models can be deserialized --- mopidy/models/serialize.py | 12 +++++++----- tests/models/test_models.py | 6 ------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/mopidy/models/serialize.py b/mopidy/models/serialize.py index 1c438efb..5002a8f7 100644 --- a/mopidy/models/serialize.py +++ b/mopidy/models/serialize.py @@ -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 diff --git a/tests/models/test_models.py b/tests/models/test_models.py index be82d3b2..5108411a 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -1168,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) From 5989d3a01707ce3b6e75192aedb7037e946bd600 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 4 May 2015 22:39:36 +0200 Subject: [PATCH 158/318] models: Simplify how we add __weakref__ to slots --- mopidy/models/immutable.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py index 485480a9..0b6ef2f7 100644 --- a/mopidy/models/immutable.py +++ b/mopidy/models/immutable.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, unicode_literals import copy -import inspect import itertools import weakref @@ -27,12 +26,6 @@ class ImmutableObjectMeta(type): attrs['__slots__'] = list(attrs.get('__slots__', [])) attrs['__slots__'].extend(['_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 @@ -56,6 +49,7 @@ class ImmutableObject(object): """ __metaclass__ = ImmutableObjectMeta + __slots__ = ['__weakref__'] def __init__(self, *args, **kwargs): for key, value in kwargs.items(): From b480311d66d8135b53a8e692c1c12786733175fe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 4 May 2015 23:41:11 +0200 Subject: [PATCH 159/318] models: Add ValidatedImmutableObject and "revert" ImmutableObject Testing with extension that use custom models it was discovered that the changes to have type safe models were a bit to invasive to be suitable for a minor release. This change fixes this by bringing back ImmutableObjects in their old form, and moving the shinny new features to ValidatedImmutableObject. A subset of the tests for ImmutableObjects have been resurrected to have some confidence in this working the way we think it should. --- mopidy/models/__init__.py | 21 ++-- mopidy/models/fields.py | 7 +- mopidy/models/immutable.py | 206 +++++++++++++++++++++++------------- tests/models/test_legacy.py | 164 ++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 86 deletions(-) create mode 100644 tests/models/test_legacy.py diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index e4e8528a..231a472a 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -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 diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index 23154df5..bd0ba9f9 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -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 diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py index 0b6ef2f7..98cd8b5b 100644 --- a/mopidy/models/immutable.py +++ b/mopidy/models/immutable.py @@ -8,71 +8,54 @@ 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 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__', [])) - attrs['__slots__'].extend(['_hash'] + fields.values()) - - 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 = [] @@ -88,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__): @@ -107,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 @@ -133,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 diff --git a/tests/models/test_legacy.py b/tests/models/test_legacy.py new file mode 100644 index 00000000..d837d738 --- /dev/null +++ b/tests/models/test_legacy.py @@ -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)) From 85871fb33d86595c1b0a6c0c1f4191d8635f8d2a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 5 May 2015 00:00:22 +0200 Subject: [PATCH 160/318] docs: Improve fields documentation --- docs/api/models.rst | 25 +++++++++++++++++++++++-- mopidy/models/fields.py | 24 ++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/api/models.rst b/docs/api/models.rst index d2d8ec0a..d25e7f34 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -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 diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index bd0ba9f9..01a03a75 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -73,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. @@ -112,7 +133,6 @@ class Integer(Field): class Collection(Field): - """ :class:`Field` for storing collections of a given type. From dd4a8f3b785641edad70a89d5a7281011c3e2167 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 5 May 2015 22:55:53 +0200 Subject: [PATCH 161/318] core: Make sure library can handle bad data from backends Note that None values are just ignored, while other bad data logs an error message and is ignored. --- mopidy/core/library.py | 93 ++++++++++++++++---------- tests/core/test_library.py | 130 ++++++++++++++++++++++++++++++++++--- 2 files changed, 181 insertions(+), 42 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index d9803d3f..1ca21457 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,16 +1,30 @@ from __future__ import absolute_import, unicode_literals import collections +import contextlib import logging import operator import urlparse +from mopidy import compat, exceptions, models from mopidy.utils import deprecation, validation logger = logging.getLogger(__name__) +@contextlib.contextmanager +def _backend_error_handling(backend): + try: + yield + except exceptions.ValidationError as e: + logger.error('%s backend returned bad data: %s', + backend.actor_ref.actor_class.__name__, e) + except Exception: + logger.exception('%s backend caused an exception.', + backend.actor_ref.actor_class.__name__) + + class LibraryController(object): pykka_traversable = True @@ -79,22 +93,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): @@ -120,11 +136,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 +168,18 @@ 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: + name = backend.actor_ref.actor_class.__name__ + logger.warning( + '%s backend returned image for URI we did not ' + 'ask for: %s', name, 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): """ @@ -313,8 +332,14 @@ class LibraryController(object): results = [] for backend, future in futures.items(): - try: - results.append(future.get()) + try: # TODO: fix all these cases so we can use common helper + result = future.get() + if result is not None: + validation.check_instance(result, models.SearchResult) + results.append(result) + except exceptions.ValidationError as e: + logger.error('%s backend returned bad data: %s', + backend.actor_ref.actor_class.__name__, e) except TypeError: backend_name = backend.actor_ref.actor_class.__name__ logger.warning( diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 89f3b284..527e5272 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -15,6 +15,7 @@ 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() @@ -24,6 +25,7 @@ class BaseCoreLibraryTest(unittest.TestCase): 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() @@ -33,6 +35,7 @@ class BaseCoreLibraryTest(unittest.TestCase): # 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 @@ -156,11 +159,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']) @@ -363,12 +369,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) @@ -443,28 +451,124 @@ class BackendFailuresCoreLibraryTest(unittest.TestCase): self.assertEqual([], self.core.library.browse(None)) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + def test_browse_backend_get_root_bad_value(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_browse_backend_get_root_none(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_browse_backend_browse_uri_exception_gets_ignored(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_browse_backend_browse_uri_bad_value(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) + def test_get_distinct_backend_exception_gets_ignored(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_distinct_backend_returns_string(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_get_distinct_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_get_distinct_backend_returns_list_if_ints(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) + def test_get_images_backend_exception_get_ignored(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_get_images_backend_returns_string(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_get_images_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_get_images_backend_returns_bad_dict(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_get_images_backend_returns_dict_with_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_get_images_backend_returns_wrong_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.warning.assert_called_with(mock.ANY, 'DummyBackend', 'foo') + + def test_lookup_backend_exceptions_gets_ignored(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_lookup_uris_backend_returns_string(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_lookup_uris_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_lookup_uris_backend_returns_bad_list(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_lookup_uri_backend_returns_string(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_lookup_uri_backend_returns_none(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_lookup_uri_backend_returns_bad_list(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) + def test_refresh_backend_exception_gets_ignored(self, logger): self.library.refresh.return_value.get.side_effect = Exception self.core.library.refresh() @@ -486,3 +590,13 @@ class BackendFailuresCoreLibraryTest(unittest.TestCase): self.library.search.return_value.get.side_effect = LookupError with self.assertRaises(LookupError): self.core.library.search(query={'any': ['foo']}) + + def test_search_backend_returns_string(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) + + def test_search_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) From 3426633c78545f7daaf6c5cca26964b65245496e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 5 May 2015 23:41:46 +0200 Subject: [PATCH 162/318] core: Make sure we handle bad mixer data and exceptions. --- mopidy/core/mixer.py | 52 +++++++++++++++++++++++++++++++++------- tests/core/test_mixer.py | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index fde7ee5a..94188d50 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -1,10 +1,24 @@ from __future__ import absolute_import, unicode_literals +import contextlib import logging +from mopidy import exceptions from mopidy.utils import validation +@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__) + + logger = logging.getLogger(__name__) @@ -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. @@ -37,8 +58,12 @@ class MixerController(object): if self._mixer is None: return False - else: - return self._mixer.set_volume(volume).get() + + with _mixer_error_handling(self._mixer): + # TODO: log non-bool return values? + return bool(self._mixer.set_volume(volume).get()) + + return False def get_mute(self): """Get mute state. @@ -46,8 +71,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. @@ -59,5 +91,9 @@ class MixerController(object): validation.check_boolean(mute) if self._mixer is None: return False - else: - return self._mixer.set_mute(bool(mute)).get() + + with _mixer_error_handling(self._mixer): + # TODO: log non-bool return values? + return bool(self._mixer.set_mute(bool(mute)).get()) + + return None diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index c4ef7fe9..61e054d0 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -92,3 +92,51 @@ 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 CoreBadMixerTest(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=[]) + + def test_get_volume_raises_exception(self): + self.mixer.get_volume.return_value.get.side_effect = Exception + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_get_volume_returns_negative(self): + self.mixer.get_volume.return_value.get.return_value = -1 + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_get_volume_returns_out_of_bound(self): + self.mixer.get_volume.return_value.get.return_value = 1000 + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_get_volume_returns_wrong_type(self): + self.mixer.get_volume.return_value.get.return_value = '12' + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_set_volume_exception(self): + self.mixer.set_volume.return_value.get.side_effect = Exception + self.assertFalse(self.core.mixer.set_volume(30)) + + def test_set_volume_non_bool_return_value(self): + self.mixer.set_volume.return_value.get.return_value = 'done' + self.assertIs(self.core.mixer.set_volume(30), True) + + def test_get_mute_raises_exception(self): + self.mixer.get_mute.return_value.get.side_effect = Exception + self.assertEqual(self.core.mixer.get_mute(), None) + + def test_get_mute_returns_wrong_type(self): + self.mixer.get_mute.return_value.get.return_value = '12' + self.assertEqual(self.core.mixer.get_mute(), None) + + def test_set_mute_exception(self): + self.mixer.set_mute.return_value.get.side_effect = Exception + self.assertFalse(self.core.mixer.set_mute(True)) + + def test_set_mute_non_bool_return_value(self): + self.mixer.set_mute.return_value.get.return_value = 'done' + self.assertIs(self.core.mixer.set_mute(True), True) From e7b241e18b74ad4984b372b6ae1d6fdb91f9361e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 6 May 2015 00:28:58 +0200 Subject: [PATCH 163/318] core: Update playlists to handle bad data from backends and exceptions --- mopidy/core/playlists.py | 95 +++++++++++++++++++++++++++--------- tests/core/test_playlists.py | 82 +++++++++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index aa5befaf..d14f2fcd 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -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.models import Playlist, Ref from mopidy.utils 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 PlaylistsController(object): pykka_traversable = True @@ -36,15 +52,16 @@ class PlaylistsController(object): results = [] for backend, future in futures.items(): try: - results.extend(future.get()) + with _backend_error_handling(backend, 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__ 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): """ @@ -85,7 +110,7 @@ class PlaylistsController(object): """ deprecation.warn('core.playlists.get_playlists') - playlist_refs = self.as_list() + playlist_refs = self.as_list() or [] if include_tracks: playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs} @@ -125,11 +150,20 @@ class PlaylistsController(object): if uri_scheme in self.backends.with_playlists: backend = self.backends.with_playlists[uri_scheme] else: - # TODO: this fallback looks suspicious + # TODO: loop over backends until one of them doesn't return None backend = list(self.backends.with_playlists.values())[0] - playlist = backend.playlists.create(name).get() - listener.CoreListener.send('playlist_changed', playlist=playlist) - return playlist + + with _backend_error_handling(backend): + playlist = backend.playlists.create(name).get() + + if playlist is None: + return None + + validation.check_instance(playlist, Playlist) + listener.CoreListener.send('playlist_changed', playlist=playlist) + return playlist + + return None def delete(self, uri): """ @@ -145,8 +179,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 +232,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 +270,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 +306,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, 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 diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index febff62b..0f73ac14 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -295,15 +295,69 @@ 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 + 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_as_list_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_as_list_backend_bad_value(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) + + def test_get_items_backend_exception_gets_caught(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_get_items_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_get_items_backend_returns_bad_value(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) + + def test_create_backend_exception_gets_caught(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_create_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_create_backend_returns_bad_value(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) + + def test_delete_backend_exception_gets_caught(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') + + def test_lookup_backend_exception_gets_caught(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_lookup_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_lookup_backend_returns_bad_value(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.listener.CoreListener.send') def test_refresh_backend_exception_gets_ignored(self, send, logger): @@ -318,3 +372,21 @@ class BackendFailuresCorePlaylistsTest(unittest.TestCase): self.core.playlists.refresh('dummy') self.assertFalse(send.called) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_save_backend_exception_gets_caught(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_save_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_save_backend_returns_bad_value(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) From 4aa984207b1ccef3b1163cace009b582dea2ba42 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 6 May 2015 01:01:12 +0200 Subject: [PATCH 164/318] tests: Split up core bad backend tests and unify naming --- tests/core/test_library.py | 137 ++++++++++++++++++++--------------- tests/core/test_mixer.py | 34 ++++++--- tests/core/test_playlists.py | 67 ++++++++++++----- 3 files changed, 150 insertions(+), 88 deletions(-) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 527e5272..525425ca 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -429,8 +429,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') @@ -445,158 +444,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_get_root_bad_value(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_browse_backend_get_root_none(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_browse_backend_browse_uri_exception_gets_ignored(self, logger): + 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_browse_backend_browse_uri_bad_value(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) - def test_get_distinct_backend_exception_gets_ignored(self, logger): + +@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_distinct_backend_returns_string(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_get_distinct_backend_returns_none(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_get_distinct_backend_returns_list_if_ints(self, logger): + 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) - def test_get_images_backend_exception_get_ignored(self, logger): + +@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({uri: tuple()}, self.core.library.get_images([uri])) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') - def test_get_images_backend_returns_string(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_get_images_backend_returns_none(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_get_images_backend_returns_bad_dict(self, logger): + 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_get_images_backend_returns_dict_with_none(self, logger): + 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_get_images_backend_returns_wrong_uri(self, logger): + 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.warning.assert_called_with(mock.ANY, 'DummyBackend', 'foo') - def test_lookup_backend_exceptions_gets_ignored(self, logger): + +@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({uri: []}, self.core.library.lookup(uris=[uri])) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') - def test_lookup_uris_backend_returns_string(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_lookup_uris_backend_returns_none(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_lookup_uris_backend_returns_bad_list(self, logger): + 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_lookup_uri_backend_returns_string(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_lookup_uri_backend_returns_none(self, logger): + 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_lookup_uri_backend_returns_bad_list(self, logger): + 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) - def test_refresh_backend_exception_gets_ignored(self, logger): + +@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_search_backend_returns_string(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) - - def test_search_backend_returns_none(self, logger): + 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) diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 61e054d0..5444cae6 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -94,49 +94,61 @@ class CoreNoneMixerListenerTest(unittest.TestCase): self.assertEqual(send.call_count, 0) -class CoreBadMixerTest(unittest.TestCase): +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=[]) - def test_get_volume_raises_exception(self): + +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_get_volume_returns_negative(self): + 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_get_volume_returns_out_of_bound(self): + 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_get_volume_returns_wrong_type(self): + 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) - def test_set_volume_exception(self): + +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_set_volume_non_bool_return_value(self): + def test_backend_returns_wrong_type(self): self.mixer.set_volume.return_value.get.return_value = 'done' self.assertIs(self.core.mixer.set_volume(30), True) - def test_get_mute_raises_exception(self): + +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_get_mute_returns_wrong_type(self): + 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) - def test_set_mute_exception(self): + +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_set_mute_non_bool_return_value(self): + def test_backend_returns_wrong_type(self): self.mixer.set_mute.return_value.get.return_value = 'done' self.assertIs(self.core.mixer.set_mute(True), True) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 0f73ac14..d4e075ad 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -281,8 +281,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,98 +293,126 @@ class BackendFailuresCorePlaylistsTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[self.backend]) - def test_as_list_backend_exception_gets_ignored(self, logger): + +@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_as_list_backend_returns_none(self, logger): + 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_as_list_backend_bad_value(self, logger): + 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) - def test_get_items_backend_exception_gets_caught(self, logger): + +@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 self.assertIsNone(self.core.playlists.get_items('dummy:/1')) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') - def test_get_items_backend_returns_none(self, logger): + 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_get_items_backend_returns_bad_value(self, logger): + 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) - def test_create_backend_exception_gets_caught(self, logger): + +@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_create_backend_returns_none(self, logger): + 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_create_backend_returns_bad_value(self, logger): + 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) - def test_delete_backend_exception_gets_caught(self, logger): + +@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') - def test_lookup_backend_exception_gets_caught(self, logger): + +@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_lookup_backend_returns_none(self, logger): + 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_lookup_backend_returns_bad_value(self, logger): + 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') - def test_save_backend_exception_gets_caught(self, logger): + +@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_save_backend_returns_none(self, logger): + 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_save_backend_returns_bad_value(self, logger): + 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)) From 9f64a8719aae6e04876d80d3d0d32a04f82913f9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 6 May 2015 01:02:49 +0200 Subject: [PATCH 165/318] docs: Add core not trusting backends entry to changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 63f432ba..b410fd39 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,8 @@ Core API ``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`, :issue:`1140`) +- Update core to handle backend crashes and bad data. (Fixes: :issue:`1161`) + Models ------ From 636639a201bd9cfea36af53e7e4ec2c72d2d479f Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 6 May 2015 14:50:21 +0200 Subject: [PATCH 166/318] Fix #1162: Ignore None results and exceptions from PlaylistsProvider.create(). --- docs/changelog.rst | 9 +++++++++ mopidy/core/playlists.py | 18 ++++++++++++------ tests/core/test_playlists.py | 26 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 10c413ec..00cee7b8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,15 @@ Changelog This changelog is used to track all major changes to Mopidy. +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) =================== diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 669e1f35..1b4c2692 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -118,13 +118,19 @@ class PlaylistsController(object): :rtype: :class:`mopidy.models.Playlist` """ 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: + try: + playlist = backend.playlists.create(name).get() + except Exception: + playlist = None + # Workaround for playlist providers that return None from create() + if not playlist: + continue + listener.CoreListener.send('playlist_changed', playlist=playlist) + return playlist def delete(self, uri): """ diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 081f73e6..e02f6204 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -118,6 +118,32 @@ class PlaylistsTest(unittest.TestCase): 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().get.return_value = None + self.sp1.reset_mock() + self.sp2.create().get.return_value = playlist + self.sp2.reset_mock() + + 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().get.side_effect = Exception + self.sp1.reset_mock() + self.sp2.create().get.return_value = playlist + self.sp2.reset_mock() + + 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 From 6d82cdb611902bfae7ddbeec39992cc2b5534670 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 6 May 2015 21:06:30 +0200 Subject: [PATCH 167/318] tests: Cleanup reset_mock() usage --- tests/core/test_events.py | 13 ----------- tests/core/test_library.py | 42 ++++++++++++------------------------ tests/core/test_playback.py | 6 ++---- tests/core/test_playlists.py | 27 ++++++++--------------- tests/http/test_events.py | 4 ---- 5 files changed, 25 insertions(+), 67 deletions(-) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 157acffd..b6cd25b9 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -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() diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 89f3b284..4a8937c3 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -16,8 +16,7 @@ class BaseCoreLibraryTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] 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 @@ -25,8 +24,7 @@ class BaseCoreLibraryTest(unittest.TestCase): self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2'] 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 @@ -65,20 +63,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 +101,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 +113,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 +132,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, [ @@ -199,10 +191,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 +224,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 +242,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']}) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 1837ac80..7896ed4e 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -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 diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 9d52efd4..063b4057 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -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') @@ -101,10 +100,8 @@ class PlaylistTest(BasePlaylistsTest): def test_create_without_uri_scheme_ignores_none_result(self): playlist = Playlist() - self.sp1.create().get.return_value = None - self.sp1.reset_mock() - self.sp2.create().get.return_value = playlist - self.sp2.reset_mock() + self.sp1.create.return_value.get.return_value = None + self.sp2.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo') @@ -114,10 +111,8 @@ class PlaylistTest(BasePlaylistsTest): def test_create_without_uri_scheme_ignores_exception(self): playlist = Playlist() - self.sp1.create().get.side_effect = Exception - self.sp1.reset_mock() - self.sp2.create().get.return_value = playlist - self.sp2.reset_mock() + self.sp1.create.return_value.get.side_effect = Exception + self.sp2.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo') @@ -127,8 +122,7 @@ class PlaylistTest(BasePlaylistsTest): 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') @@ -138,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') @@ -216,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) @@ -227,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) diff --git a/tests/http/test_events.py b/tests/http/test_events.py index 43d9db58..dd1760a3 100644 --- a/tests/http/test_events.py +++ b/tests/http/test_events.py @@ -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( From c01f8679bc6e1140c46e5b02e488e709c93d3a8e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 6 May 2015 22:34:44 +0200 Subject: [PATCH 168/318] core: Address review comments for do not trust backends PR --- mopidy/core/library.py | 41 +++++++++++++++++--------------------- mopidy/core/mixer.py | 18 +++++++++-------- mopidy/core/playlists.py | 12 +++++------ tests/core/test_library.py | 2 +- tests/core/test_mixer.py | 6 ++++-- 5 files changed, 39 insertions(+), 40 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 1ca21457..8e6e4015 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -14,13 +14,15 @@ logger = logging.getLogger(__name__) @contextlib.contextmanager -def _backend_error_handling(backend): +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: + except Exception as e: + if reraise and isinstance(e, reraise): + raise logger.exception('%s backend caused an exception.', backend.actor_ref.actor_class.__name__) @@ -174,10 +176,8 @@ class LibraryController(object): validation.check_instance(future.get(), collections.Mapping) for uri, images in future.get().items(): if uri not in uris: - name = backend.actor_ref.actor_class.__name__ - logger.warning( - '%s backend returned image for URI we did not ' - 'ask for: %s', name, uri) + raise exceptions.ValidationError( + 'Got unknown image URI: %s' % uri) validation.check_instances(images, models.Image) results[uri] += tuple(images) return results @@ -330,31 +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: # TODO: fix all these cases so we can use common helper - result = future.get() - if result is not None: - validation.check_instance(result, models.SearchResult) - results.append(result) - except exceptions.ValidationError as e: - logger.error('%s backend returned bad data: %s', - backend.actor_ref.actor_class.__name__, e) + try: + 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): diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 94188d50..d68cb842 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -7,6 +7,9 @@ from mopidy import exceptions from mopidy.utils import validation +logger = logging.getLogger(__name__) + + @contextlib.contextmanager def _mixer_error_handling(mixer): try: @@ -19,9 +22,6 @@ def _mixer_error_handling(mixer): mixer.actor_ref.actor_class.__name__) -logger = logging.getLogger(__name__) - - class MixerController(object): pykka_traversable = True @@ -60,10 +60,11 @@ class MixerController(object): return False with _mixer_error_handling(self._mixer): - # TODO: log non-bool return values? - return bool(self._mixer.set_volume(volume).get()) + result = self._mixer.set_volume(volume).get() + validation.check_instance(result, bool) + return result - return False + return None def get_mute(self): """Get mute state. @@ -93,7 +94,8 @@ class MixerController(object): return False with _mixer_error_handling(self._mixer): - # TODO: log non-bool return values? - return bool(self._mixer.set_mute(bool(mute)).get()) + result = self._mixer.set_mute(bool(mute)).get() + validation.check_instance(result, bool) + return result return None diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index d14f2fcd..af983872 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -50,15 +50,15 @@ 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: - with _backend_error_handling(backend, NotImplementedError): + 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) @@ -110,7 +110,7 @@ class PlaylistsController(object): """ deprecation.warn('core.playlists.get_playlists') - playlist_refs = self.as_list() or [] + playlist_refs = self.as_list() if include_tracks: playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs} @@ -145,7 +145,7 @@ 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] @@ -310,7 +310,7 @@ class PlaylistsController(object): return None # TODO: we let AssertionError error through due to legacy tests :/ - with _backend_error_handling(backend, AssertionError): + with _backend_error_handling(backend, reraise=AssertionError): playlist = backend.playlists.save(playlist).get() playlist is None or validation.check_instance(playlist, Playlist) if playlist: diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 525425ca..c368baf2 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -536,7 +536,7 @@ class GetImagesBadBackendTest(MockBackendCoreLibraryBase): uri = 'dummy:/1' self.library.get_images.return_value.get.return_value = {'foo': []} self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) - logger.warning.assert_called_with(mock.ANY, 'DummyBackend', 'foo') + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.library.logger') diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 5444cae6..40e13aea 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -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) @@ -129,7 +131,7 @@ class SetVolumeBadBackendTest(MockBackendCoreMixerBase): def test_backend_returns_wrong_type(self): self.mixer.set_volume.return_value.get.return_value = 'done' - self.assertIs(self.core.mixer.set_volume(30), True) + self.assertIs(self.core.mixer.set_volume(30), None) class GetMuteBadBackendTest(MockBackendCoreMixerBase): @@ -151,4 +153,4 @@ class SetMuteBadBackendTest(MockBackendCoreMixerBase): def test_backend_returns_wrong_type(self): self.mixer.set_mute.return_value.get.return_value = 'done' - self.assertIs(self.core.mixer.set_mute(True), True) + self.assertIs(self.core.mixer.set_mute(True), None) From 4d608dd431262eb16461ed65fdfc6015b7e9e339 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 6 May 2015 23:00:53 +0200 Subject: [PATCH 169/318] core: Add get_current_tlid shortcut --- docs/changelog.rst | 3 +++ mopidy/core/playback.py | 16 +++++++++++++--- tests/core/test_playback.py | 11 +++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2850a27a..0a19e834 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,9 @@ 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`) + Models ------ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 3605db0f..5836ff68 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -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 diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 7896ed4e..4b81dd8b 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -121,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): From 29c66f7bc8856c28bb1f3dae2a8c44a22e412ed7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 7 May 2015 00:13:58 +0200 Subject: [PATCH 170/318] core: Correct volume/mute return values --- mopidy/core/mixer.py | 8 ++++---- tests/core/test_mixer.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index d68cb842..2815fb0c 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -57,14 +57,14 @@ class MixerController(object): validation.check_integer(volume, min=0, max=100) if self._mixer is None: - return False + 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 None + return False def get_mute(self): """Get mute state. @@ -91,11 +91,11 @@ class MixerController(object): """ validation.check_boolean(mute) if self._mixer is None: - return False + 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 None + return False diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 40e13aea..45241fec 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -131,7 +131,7 @@ class SetVolumeBadBackendTest(MockBackendCoreMixerBase): def test_backend_returns_wrong_type(self): self.mixer.set_volume.return_value.get.return_value = 'done' - self.assertIs(self.core.mixer.set_volume(30), None) + self.assertFalse(self.core.mixer.set_volume(30)) class GetMuteBadBackendTest(MockBackendCoreMixerBase): @@ -153,4 +153,4 @@ class SetMuteBadBackendTest(MockBackendCoreMixerBase): def test_backend_returns_wrong_type(self): self.mixer.set_mute.return_value.get.return_value = 'done' - self.assertIs(self.core.mixer.set_mute(True), None) + self.assertFalse(self.core.mixer.set_mute(True)) From ae07603da09d007e0aa76e781085f062da44e704 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:02:23 +0200 Subject: [PATCH 171/318] docs: Add module name to API docs headers In the same way the Python stdlib docs does. --- docs/api/audio.rst | 6 +++--- docs/api/backends.rst | 6 +++--- docs/api/commands.rst | 6 +++--- docs/api/config.rst | 6 +++--- docs/api/core.rst | 6 +++--- docs/api/ext.rst | 6 +++--- docs/api/http.rst | 3 --- docs/api/mixer.rst | 6 +++--- docs/api/models.rst | 6 +++--- docs/api/zeroconf.rst | 6 +++--- 10 files changed, 27 insertions(+), 30 deletions(-) diff --git a/docs/api/audio.rst b/docs/api/audio.rst index 76389fb4..1e86625c 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -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 diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 5e938357..f7218876 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -1,8 +1,8 @@ .. _backend-api: -*********** -Backend API -*********** +************************************* +:mod:`mopidy.backend` --- Backend API +************************************* .. module:: mopidy.backend :synopsis: The API implemented by backends diff --git a/docs/api/commands.rst b/docs/api/commands.rst index f0469350..216c4d46 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -1,8 +1,8 @@ .. _commands-api: -************ -Commands API -************ +*************************************** +:mod:`mopidy.commands` --- Commands API +*************************************** .. automodule:: mopidy.commands :synopsis: Commands API for Mopidy CLI. diff --git a/docs/api/config.rst b/docs/api/config.rst index 8b005a9d..289bda5a 100644 --- a/docs/api/config.rst +++ b/docs/api/config.rst @@ -1,8 +1,8 @@ .. _config-api: -********** -Config API -********** +*********************************** +:mod:`mopidy.config` --- Config API +*********************************** .. automodule:: mopidy.config :synopsis: Config API for config loading and validation diff --git a/docs/api/core.rst b/docs/api/core.rst index 38703222..9134afed 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -1,8 +1,8 @@ .. _core-api: -******** -Core API -******** +******************************* +:mod:`mopidy.core` --- Core API +******************************* .. module:: mopidy.core :synopsis: Core API for use by frontends diff --git a/docs/api/ext.rst b/docs/api/ext.rst index 11908920..220c763b 100644 --- a/docs/api/ext.rst +++ b/docs/api/ext.rst @@ -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`. diff --git a/docs/api/http.rst b/docs/api/http.rst index 9a7d56bb..e428e471 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -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 `, around the JSON-RPC over diff --git a/docs/api/mixer.rst b/docs/api/mixer.rst index 6f02e3c9..272bf3c7 100644 --- a/docs/api/mixer.rst +++ b/docs/api/mixer.rst @@ -1,8 +1,8 @@ .. _mixer-api: -*************** -Audio mixer API -*************** +*************************************** +:mod:`mopidy.mixer` --- Audio mixer API +*************************************** .. module:: mopidy.mixer :synopsis: The audio mixer API diff --git a/docs/api/models.rst b/docs/api/models.rst index d25e7f34..07702555 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -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 diff --git a/docs/api/zeroconf.rst b/docs/api/zeroconf.rst index 7cdd93f0..552c5771 100644 --- a/docs/api/zeroconf.rst +++ b/docs/api/zeroconf.rst @@ -1,8 +1,8 @@ .. _zeroconf-api: -************ -Zeroconf API -************ +*************************************** +:mod:`mopidy.zeroconf` --- Zeroconf API +*************************************** .. module:: mopidy.zeroconf :synopsis: Helper for publishing of services on Zeroconf From ccecf6b6bf4176cbbae0cb631386508d21e97659 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:08:02 +0200 Subject: [PATCH 172/318] docs: Remove plurality from backends/frontends API docs --- docs/api/{backends.rst => backend.rst} | 0 docs/api/{frontends.rst => frontend.rst} | 0 docs/api/index.rst | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/api/{backends.rst => backend.rst} (100%) rename docs/api/{frontends.rst => frontend.rst} (100%) diff --git a/docs/api/backends.rst b/docs/api/backend.rst similarity index 100% rename from docs/api/backends.rst rename to docs/api/backend.rst diff --git a/docs/api/frontends.rst b/docs/api/frontend.rst similarity index 100% rename from docs/api/frontends.rst rename to docs/api/frontend.rst diff --git a/docs/api/index.rst b/docs/api/index.rst index 3e008f00..3c57d963 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -16,10 +16,10 @@ API reference concepts models core - backends + backend audio mixer - frontends + frontend commands ext config From d02f7dca18f500cc7c44e5a957c55e2c5bce6c48 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:15:06 +0200 Subject: [PATCH 173/318] docs: Move frontend API between core and backend --- docs/api/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/index.rst b/docs/api/index.rst index 3c57d963..bdb803ee 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -16,10 +16,10 @@ API reference concepts models core + frontend backend audio mixer - frontend commands ext config From 526216b61b437caf8adb3c1879339d62c1461500 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:15:16 +0200 Subject: [PATCH 174/318] docs: Remove note header --- docs/api/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/index.rst b/docs/api/index.rst index bdb803ee..b19d9e21 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -4,7 +4,7 @@ API reference ************* -.. note:: What is public? +.. note:: Only APIs documented here are public and open for use by Mopidy extensions. From 1d82bd704350ad64ffaf8682dba8d55fe94ccc11 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:15:28 +0200 Subject: [PATCH 175/318] docs: Use consistent syntax for module headers --- docs/modules/local.rst | 6 +++--- docs/modules/mpd.rst | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/modules/local.rst b/docs/modules/local.rst index 31ca6498..014bac2a 100644 --- a/docs/modules/local.rst +++ b/docs/modules/local.rst @@ -1,6 +1,6 @@ -************************************ -:mod:`mopidy.local` -- Local backend -************************************ +************************************* +:mod:`mopidy.local` --- Local backend +************************************* For details on how to use Mopidy's local backend, see :ref:`ext-local`. diff --git a/docs/modules/mpd.rst b/docs/modules/mpd.rst index 1826e535..83650c39 100644 --- a/docs/modules/mpd.rst +++ b/docs/modules/mpd.rst @@ -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`. From 7c57c51b2eebb5d5e397cef2988b3c80b9fa760b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:16:37 +0200 Subject: [PATCH 176/318] docs: Fix unexpected indentation error --- docs/changelog.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 182db916..d010ba5f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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`) From 3d051e1a248ff7fd39e4b5a401c4d24775aa6952 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:20:05 +0200 Subject: [PATCH 177/318] docs: Remove old deps from list of mocked modules --- docs/conf.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e970bdee..e91318cc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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() From 622a3c549442609b5a5b21c1950b92826b566527 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:39:54 +0200 Subject: [PATCH 178/318] docs: Group API docs in sections --- docs/api/{concepts.rst => architecture.rst} | 6 +-- docs/api/index.rst | 46 +++++++++++++++++---- 2 files changed, 41 insertions(+), 11 deletions(-) rename docs/api/{concepts.rst => architecture.rst} (97%) diff --git a/docs/api/concepts.rst b/docs/api/architecture.rst similarity index 97% rename from docs/api/concepts.rst rename to docs/api/architecture.rst index 9c542777..b0789f49 100644 --- a/docs/api/concepts.rst +++ b/docs/api/architecture.rst @@ -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 diff --git a/docs/api/index.rst b/docs/api/index.rst index b19d9e21..3a79af5d 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -10,20 +10,50 @@ API reference extensions. -.. toctree:: - :glob: +Concepts +======== - concepts +.. toctree:: + + architecture models + + +Basics +====== + +.. toctree:: + core frontend backend - audio - mixer - commands ext - config - zeroconf + + +Web/JavaScript +============== + +.. toctree:: + http-server http js + + +Audio +===== + +.. toctree:: + + audio + mixer + + +Utilities +========= + +.. toctree:: + + config + commands + zeroconf From f96a22e5cbc778ef555dc9c241a6906a411af390 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:40:52 +0200 Subject: [PATCH 179/318] docs: Remove note on how to access core attributes The corresponding methods are now fully documented and the old attributes are deprecated. --- docs/api/http.rst | 11 +++-------- docs/api/js.rst | 11 ++++------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/docs/api/http.rst b/docs/api/http.rst index e428e471..f2a50b27 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -62,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 `_. -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:: diff --git a/docs/api/js.rst b/docs/api/js.rst index 29866d14..8771d48c 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -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 From d0418d625be360951a4c6c4d4f2c67fb50496ec4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 21:42:37 +0200 Subject: [PATCH 180/318] docs: Link from JS docs to static web client example --- docs/api/http-server.rst | 2 ++ docs/api/js.rst | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/api/http-server.rst b/docs/api/http-server.rst index 317a77c5..463abf5a 100644 --- a/docs/api/http-server.rst +++ b/docs/api/http-server.rst @@ -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 ========================= diff --git a/docs/api/js.rst b/docs/api/js.rst index 8771d48c..b98eb566 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -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 From 6fe382f37e4469fe6af8f8dfca522b0ab6f2febe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 22:34:13 +0200 Subject: [PATCH 181/318] docs: Mopidy.js supports by-name parameters Since Mopidy 0.19 / Mopidy.js 0.4 --- docs/api/js.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/api/js.rst b/docs/api/js.rst index b98eb566..62f7d438 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -215,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 From 4c8c8cd9279590518454d519d07652015e431b77 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 22:36:56 +0200 Subject: [PATCH 182/318] docs: Don't refer to when.js before it's introduced --- docs/api/js.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/api/js.rst b/docs/api/js.rst index 62f7d438..6a8e0fcd 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -268,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 `_ standard. We use the From d8bcd7f273ecdf8f5ad3967de76fbee099e62ecf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 May 2015 23:15:56 +0200 Subject: [PATCH 183/318] Rename mopidy.utils to mopidy.internal --- docs/conf.py | 2 +- docs/extensiondev.rst | 4 ++-- mopidy/__main__.py | 4 ++-- mopidy/audio/actor.py | 2 +- mopidy/audio/scan.py | 4 ++-- mopidy/commands.py | 2 +- mopidy/config/__init__.py | 2 +- mopidy/config/types.py | 2 +- mopidy/core/actor.py | 4 ++-- mopidy/core/library.py | 2 +- mopidy/core/mixer.py | 2 +- mopidy/core/playback.py | 2 +- mopidy/core/playlists.py | 2 +- mopidy/core/tracklist.py | 2 +- mopidy/http/actor.py | 2 +- mopidy/http/handlers.py | 2 +- mopidy/{utils => internal}/__init__.py | 0 mopidy/{utils => internal}/deprecation.py | 0 mopidy/{utils => internal}/deps.py | 2 +- mopidy/{utils => internal}/encoding.py | 0 mopidy/{utils => internal}/formatting.py | 0 mopidy/{utils => internal}/jsonrpc.py | 0 mopidy/{utils => internal}/log.py | 0 mopidy/{utils => internal}/network.py | 2 +- mopidy/{utils => internal}/path.py | 2 +- mopidy/{utils => internal}/process.py | 0 mopidy/{utils => internal}/timer.py | 0 mopidy/{utils => internal}/validation.py | 0 mopidy/{utils => internal}/versioning.py | 0 mopidy/{utils => internal}/xdg.py | 0 mopidy/local/commands.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/storage.py | 2 +- mopidy/local/translator.py | 6 +++--- mopidy/m3u/actor.py | 2 +- mopidy/m3u/translator.py | 15 +++++++-------- mopidy/models/immutable.py | 2 +- mopidy/mpd/actor.py | 2 +- mopidy/mpd/protocol/current_playlist.py | 2 +- mopidy/mpd/protocol/music_db.py | 2 +- mopidy/mpd/protocol/playback.py | 2 +- mopidy/mpd/session.py | 2 +- tests/audio/test_actor.py | 8 ++++---- tests/audio/test_scan.py | 2 +- tests/config/test_types.py | 2 +- tests/core/test_actor.py | 2 +- tests/core/test_events.py | 2 +- tests/core/test_library.py | 2 +- tests/core/test_playback.py | 2 +- tests/core/test_playlists.py | 2 +- tests/core/test_tracklist.py | 2 +- tests/{utils => internal}/__init__.py | 0 tests/{utils => internal}/network/__init__.py | 0 .../network/test_connection.py | 2 +- .../network/test_lineprotocol.py | 2 +- tests/{utils => internal}/network/test_server.py | 2 +- tests/{utils => internal}/network/test_utils.py | 10 +++++----- tests/{utils => internal}/test_deps.py | 2 +- tests/{utils => internal}/test_encoding.py | 12 ++++++------ tests/{utils => internal}/test_jsonrpc.py | 2 +- tests/{utils => internal}/test_path.py | 2 +- tests/{utils => internal}/test_validation.py | 2 +- tests/{utils => internal}/test_xdg.py | 2 +- tests/local/__init__.py | 2 +- tests/local/test_playback.py | 2 +- tests/local/test_tracklist.py | 2 +- tests/m3u/test_playlists.py | 2 +- tests/m3u/test_translator.py | 2 +- tests/mpd/protocol/__init__.py | 2 +- tests/mpd/protocol/test_current_playlist.py | 2 +- tests/mpd/protocol/test_playback.py | 2 +- tests/mpd/test_dispatcher.py | 2 +- tests/mpd/test_status.py | 2 +- tests/mpd/test_translator.py | 6 +++--- tests/stream/test_library.py | 6 +++--- 75 files changed, 90 insertions(+), 91 deletions(-) rename mopidy/{utils => internal}/__init__.py (100%) rename mopidy/{utils => internal}/deprecation.py (100%) rename mopidy/{utils => internal}/deps.py (99%) rename mopidy/{utils => internal}/encoding.py (100%) rename mopidy/{utils => internal}/formatting.py (100%) rename mopidy/{utils => internal}/jsonrpc.py (100%) rename mopidy/{utils => internal}/log.py (100%) rename mopidy/{utils => internal}/network.py (99%) rename mopidy/{utils => internal}/path.py (99%) rename mopidy/{utils => internal}/process.py (100%) rename mopidy/{utils => internal}/timer.py (100%) rename mopidy/{utils => internal}/validation.py (100%) rename mopidy/{utils => internal}/versioning.py (100%) rename mopidy/{utils => internal}/xdg.py (100%) rename tests/{utils => internal}/__init__.py (100%) rename tests/{utils => internal}/network/__init__.py (100%) rename tests/{utils => internal}/network/test_connection.py (99%) rename tests/{utils => internal}/network/test_lineprotocol.py (99%) rename tests/{utils => internal}/network/test_server.py (99%) rename tests/{utils => internal}/network/test_utils.py (87%) rename tests/{utils => internal}/test_deps.py (99%) rename tests/{utils => internal}/test_encoding.py (80%) rename tests/{utils => internal}/test_jsonrpc.py (99%) rename tests/{utils => internal}/test_path.py (99%) rename tests/{utils => internal}/test_validation.py (99%) rename tests/{utils => internal}/test_xdg.py (98%) diff --git a/docs/conf.py b/docs/conf.py index e91318cc..cc760720 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -95,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]) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index a2a5f463..1e25f48b 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -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 diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 9ec9769f..6584146f 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -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: diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 4577c3f7..72750bdf 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -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__) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 385c41af..cf370052 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -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() diff --git a/mopidy/commands.py b/mopidy/commands.py index 2414348b..29564779 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -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__) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index fd914994..fc6dcb60 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -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__) diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 8359766f..9d673c43 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -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): diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index c3967f6a..b6318492 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -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( diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 8e6e4015..f801836a 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -7,7 +7,7 @@ import operator import urlparse from mopidy import compat, exceptions, models -from mopidy.utils import deprecation, validation +from mopidy.internal import deprecation, validation logger = logging.getLogger(__name__) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 2815fb0c..649ff270 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -4,7 +4,7 @@ import contextlib import logging from mopidy import exceptions -from mopidy.utils import validation +from mopidy.internal import validation logger = logging.getLogger(__name__) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 5836ff68..6d17620a 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -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__) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index c5e6d0cc..086806cc 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -6,8 +6,8 @@ import urlparse from mopidy import exceptions from mopidy.core import listener +from mopidy.internal import deprecation, validation from mopidy.models import Playlist, Ref -from mopidy.utils import deprecation, validation logger = logging.getLogger(__name__) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 692762ef..028e7c02 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -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__) diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index 200ef833..5fe29134 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -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__) diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 228c245c..a752a4f0 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -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__) diff --git a/mopidy/utils/__init__.py b/mopidy/internal/__init__.py similarity index 100% rename from mopidy/utils/__init__.py rename to mopidy/internal/__init__.py diff --git a/mopidy/utils/deprecation.py b/mopidy/internal/deprecation.py similarity index 100% rename from mopidy/utils/deprecation.py rename to mopidy/internal/deprecation.py diff --git a/mopidy/utils/deps.py b/mopidy/internal/deps.py similarity index 99% rename from mopidy/utils/deps.py rename to mopidy/internal/deps.py index aafede9d..1f363657 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/internal/deps.py @@ -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): diff --git a/mopidy/utils/encoding.py b/mopidy/internal/encoding.py similarity index 100% rename from mopidy/utils/encoding.py rename to mopidy/internal/encoding.py diff --git a/mopidy/utils/formatting.py b/mopidy/internal/formatting.py similarity index 100% rename from mopidy/utils/formatting.py rename to mopidy/internal/formatting.py diff --git a/mopidy/utils/jsonrpc.py b/mopidy/internal/jsonrpc.py similarity index 100% rename from mopidy/utils/jsonrpc.py rename to mopidy/internal/jsonrpc.py diff --git a/mopidy/utils/log.py b/mopidy/internal/log.py similarity index 100% rename from mopidy/utils/log.py rename to mopidy/internal/log.py diff --git a/mopidy/utils/network.py b/mopidy/internal/network.py similarity index 99% rename from mopidy/utils/network.py rename to mopidy/internal/network.py index 000382e3..4b8b35fe 100644 --- a/mopidy/utils/network.py +++ b/mopidy/internal/network.py @@ -11,7 +11,7 @@ import gobject import pykka -from mopidy.utils import encoding +from mopidy.internal import encoding logger = logging.getLogger(__name__) diff --git a/mopidy/utils/path.py b/mopidy/internal/path.py similarity index 99% rename from mopidy/utils/path.py rename to mopidy/internal/path.py index 37b6cdb1..3a41d930 100644 --- a/mopidy/utils/path.py +++ b/mopidy/internal/path.py @@ -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__) diff --git a/mopidy/utils/process.py b/mopidy/internal/process.py similarity index 100% rename from mopidy/utils/process.py rename to mopidy/internal/process.py diff --git a/mopidy/utils/timer.py b/mopidy/internal/timer.py similarity index 100% rename from mopidy/utils/timer.py rename to mopidy/internal/timer.py diff --git a/mopidy/utils/validation.py b/mopidy/internal/validation.py similarity index 100% rename from mopidy/utils/validation.py rename to mopidy/internal/validation.py diff --git a/mopidy/utils/versioning.py b/mopidy/internal/versioning.py similarity index 100% rename from mopidy/utils/versioning.py rename to mopidy/internal/versioning.py diff --git a/mopidy/utils/xdg.py b/mopidy/internal/xdg.py similarity index 100% rename from mopidy/utils/xdg.py rename to mopidy/internal/xdg.py diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index c8c70216..7033f3aa 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -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__) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 22fcfa5b..715b5c5d 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -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__) diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py index 21d278e5..1808c4a2 100644 --- a/mopidy/local/storage.py +++ b/mopidy/local/storage.py @@ -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__) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 92b20a7b..6e5c9c01 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -5,20 +5,20 @@ 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)) + return path.path_to_uri(local_track_uri_to_path(uri, media_dir)) def local_track_uri_to_path(uri, media_dir): if 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) diff --git a/mopidy/m3u/actor.py b/mopidy/m3u/actor.py index 3908d938..fe959d86 100644 --- a/mopidy/m3u/actor.py +++ b/mopidy/m3u/actor.py @@ -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__) diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py index 177ab6c3..96a47fdc 100644 --- a/mopidy/m3u/translator.py +++ b/mopidy/m3u/translator.py @@ -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 diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py index 98cd8b5b..8bbf568b 100644 --- a/mopidy/models/immutable.py +++ b/mopidy/models/immutable.py @@ -4,8 +4,8 @@ import copy import itertools import weakref +from mopidy.internal import deprecation from mopidy.models.fields import Field -from mopidy.utils import deprecation class ImmutableObject(object): diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 36775578..8eb59c1f 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -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__) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index f93722ee..f44abb95 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -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') diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 0350fc21..510d3ac1 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -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', diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index ce3174d7..333e1ccb 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -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) diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index adbf6cc3..68550f3b 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -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__) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 7d5f6148..046971a8 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -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 diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index ff5a4641..c558835e 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -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 diff --git a/tests/config/test_types.py b/tests/config/test_types.py index be1ab829..40226c51 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -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' diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 520c5026..410933d2 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -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): diff --git a/tests/core/test_events.py b/tests/core/test_events.py index b6cd25b9..7c8eba1d 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -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 diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 2f244ce7..941f1831 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -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): diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 4b81dd8b..67f5841e 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -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 diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 2dabf93b..029254a8 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -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): diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 6339a18c..83b576ea 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -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): diff --git a/tests/utils/__init__.py b/tests/internal/__init__.py similarity index 100% rename from tests/utils/__init__.py rename to tests/internal/__init__.py diff --git a/tests/utils/network/__init__.py b/tests/internal/network/__init__.py similarity index 100% rename from tests/utils/network/__init__.py rename to tests/internal/network/__init__.py diff --git a/tests/utils/network/test_connection.py b/tests/internal/network/test_connection.py similarity index 99% rename from tests/utils/network/test_connection.py rename to tests/internal/network/test_connection.py index 3ad1df6b..8ae7d15c 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/internal/network/test_connection.py @@ -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 diff --git a/tests/utils/network/test_lineprotocol.py b/tests/internal/network/test_lineprotocol.py similarity index 99% rename from tests/utils/network/test_lineprotocol.py rename to tests/internal/network/test_lineprotocol.py index d3548117..586d180e 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/internal/network/test_lineprotocol.py @@ -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 diff --git a/tests/utils/network/test_server.py b/tests/internal/network/test_server.py similarity index 99% rename from tests/utils/network/test_server.py rename to tests/internal/network/test_server.py index 5ea64fca..af8effd2 100644 --- a/tests/utils/network/test_server.py +++ b/tests/internal/network/test_server.py @@ -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 diff --git a/tests/utils/network/test_utils.py b/tests/internal/network/test_utils.py similarity index 87% rename from tests/utils/network/test_utils.py rename to tests/internal/network/test_utils.py index 55d68a99..a769ff93 100644 --- a/tests/utils/network/test_utils.py +++ b/tests/internal/network/test_utils.py @@ -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() diff --git a/tests/utils/test_deps.py b/tests/internal/test_deps.py similarity index 99% rename from tests/utils/test_deps.py rename to tests/internal/test_deps.py index 394fba85..27e6f629 100644 --- a/tests/utils/test_deps.py +++ b/tests/internal/test_deps.py @@ -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): diff --git a/tests/utils/test_encoding.py b/tests/internal/test_encoding.py similarity index 80% rename from tests/utils/test_encoding.py rename to tests/internal/test_encoding.py index 2ec7e529..cc8987ce 100644 --- a/tests/utils/test_encoding.py +++ b/tests/internal/test_encoding.py @@ -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) diff --git a/tests/utils/test_jsonrpc.py b/tests/internal/test_jsonrpc.py similarity index 99% rename from tests/utils/test_jsonrpc.py rename to tests/internal/test_jsonrpc.py index 160afc4d..b2103caa 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/internal/test_jsonrpc.py @@ -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 diff --git a/tests/utils/test_path.py b/tests/internal/test_path.py similarity index 99% rename from tests/utils/test_path.py rename to tests/internal/test_path.py index 1acd7271..503d2490 100644 --- a/tests/utils/test_path.py +++ b/tests/internal/test_path.py @@ -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 diff --git a/tests/utils/test_validation.py b/tests/internal/test_validation.py similarity index 99% rename from tests/utils/test_validation.py rename to tests/internal/test_validation.py index f211c003..a46a3b59 100644 --- a/tests/utils/test_validation.py +++ b/tests/internal/test_validation.py @@ -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(): diff --git a/tests/utils/test_xdg.py b/tests/internal/test_xdg.py similarity index 98% rename from tests/utils/test_xdg.py rename to tests/internal/test_xdg.py index eab595a4..521447f7 100644 --- a/tests/utils/test_xdg.py +++ b/tests/internal/test_xdg.py @@ -6,7 +6,7 @@ import mock import pytest -from mopidy.utils import xdg +from mopidy.internal import xdg @pytest.yield_fixture diff --git a/tests/local/__init__.py b/tests/local/__init__.py index 3841a1e4..7f3cfb33 100644 --- a/tests/local/__init__.py +++ b/tests/local/__init__.py @@ -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): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 8aedcfbc..23e427d9 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -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 diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index a0add637..63ef8fde 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -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 diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index a8caf8fd..f9a7f04a 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -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 diff --git a/tests/m3u/test_translator.py b/tests/m3u/test_translator.py index 32eb9f3b..cf0bf69f 100644 --- a/tests/m3u/test_translator.py +++ b/tests/m3u/test_translator.py @@ -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 diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 4b009407..e66bf88a 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -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 diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 6ec53adc..3b7540b5 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -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 diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 6121f540..b9adb646 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -3,8 +3,8 @@ from __future__ import absolute_import, unicode_literals import unittest from mopidy.core import PlaybackState +from mopidy.internal import deprecation from mopidy.models import Track -from mopidy.utils import deprecation from tests.mpd import protocol diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index be2bf608..e5eec0f9 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -5,9 +5,9 @@ import unittest import pykka from mopidy import core +from mopidy.internal import deprecation from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError -from mopidy.utils import deprecation from tests import dummy_backend diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index f6390e53..d36ad4dc 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -6,10 +6,10 @@ import pykka from mopidy import core from mopidy.core import PlaybackState +from mopidy.internal import deprecation from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status -from mopidy.utils import deprecation from tests import dummy_backend, dummy_mixer diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 055932fc..646a22b2 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -2,9 +2,9 @@ from __future__ import absolute_import, unicode_literals import unittest +from mopidy.internal import path from mopidy.models import Album, Artist, Playlist, TlTrack, Track from mopidy.mpd import translator -from mopidy.utils.path import mtime class TrackMpdFormatTest(unittest.TestCase): @@ -27,10 +27,10 @@ class TrackMpdFormatTest(unittest.TestCase): def setUp(self): # noqa: N802 self.media_dir = '/dir/subdir' - mtime.set_fake_time(1234567) + path.mtime.set_fake_time(1234567) def tearDown(self): # noqa: N802 - mtime.undo_fake() + path.mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): # TODO: this is likely wrong, see: diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index b2410bb7..3962159c 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -11,9 +11,9 @@ import pygst pygst.require('0.10') import gst # noqa: pygst magic is needed to import correct gst +from mopidy.internal import path from mopidy.models import Track from mopidy.stream import actor -from mopidy.utils.path import path_to_uri from tests import path_to_data_dir @@ -23,7 +23,7 @@ class LibraryProviderTest(unittest.TestCase): def setUp(self): # noqa: N802 self.backend = mock.Mock() self.backend.uri_schemes = ['file'] - self.uri = path_to_uri(path_to_data_dir('song1.wav')) + self.uri = path.path_to_uri(path_to_data_dir('song1.wav')) def test_lookup_ignores_unknown_scheme(self): library = actor.StreamLibraryProvider(self.backend, 1000, [], {}) @@ -34,7 +34,7 @@ class LibraryProviderTest(unittest.TestCase): self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) def test_lookup_respects_blacklist_globbing(self): - blacklist = [path_to_uri(path_to_data_dir('')) + '*'] + blacklist = [path.path_to_uri(path_to_data_dir('')) + '*'] library = actor.StreamLibraryProvider(self.backend, 100, blacklist, {}) self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) From e30cd2cfa59f125279b6bb9ba5abe8926519f5ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 8 May 2015 00:32:09 +0200 Subject: [PATCH 184/318] local: Rename local_{track_ => }uri_to_file_uri() --- mopidy/local/playback.py | 2 +- mopidy/local/translator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index 24038426..a851239d 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -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']) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 6e5c9c01..4e248fd1 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -11,7 +11,7 @@ from mopidy.internal import path logger = logging.getLogger(__name__) -def local_track_uri_to_file_uri(uri, media_dir): +def local_uri_to_file_uri(uri, media_dir): return path.path_to_uri(local_track_uri_to_path(uri, media_dir)) From 4d5b48576073e04f675f6b24adf3ad57a2ef96df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 8 May 2015 00:33:13 +0200 Subject: [PATCH 185/318] local: Add local_uri_to_file_uri() Which replaces local_track_uri_to_file_uri() and also handles local:directory: URIs. --- mopidy/local/translator.py | 15 ++++++-- tests/local/test_translator.py | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 tests/local/test_translator.py diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 4e248fd1..371643e4 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -12,16 +12,25 @@ logger = logging.getLogger(__name__) def local_uri_to_file_uri(uri, media_dir): - return path.path_to_uri(local_track_uri_to_path(uri, media_dir)) + """Convert local track or directory URI to file URI.""" + return path.path_to_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 = 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_local_track_uri(relpath): """Convert path relative to media_dir to local track URI.""" if isinstance(relpath, compat.text_type): diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py new file mode 100644 index 00000000..1a54ae83 --- /dev/null +++ b/tests/local/test_translator.py @@ -0,0 +1,68 @@ +# 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) From 56cffa00892438110e0d7c51ca667a3cf51a58ad Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 8 May 2015 00:37:07 +0200 Subject: [PATCH 186/318] local: Test path_to_local_{directory,track}() --- tests/local/test_translator.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index 1a54ae83..afe0b340 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -66,3 +66,23 @@ def test_local_uri_to_path_errors(uri): with pytest.raises(ValueError): translator.local_uri_to_path(uri, media_dir) + + +@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 From c59784c1e84f3d520c4cd7d3d3fe668f39348e54 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 8 May 2015 00:42:25 +0200 Subject: [PATCH 187/318] local: Add path_to_file_uri() --- mopidy/local/translator.py | 8 +++++++- tests/local/test_translator.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 371643e4..856e9d4c 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) def local_uri_to_file_uri(uri, media_dir): """Convert local track or directory URI to file URI.""" - return path.path_to_uri(local_uri_to_path(uri, media_dir)) + return path_to_file_uri(local_uri_to_path(uri, media_dir)) def local_uri_to_path(uri, media_dir): @@ -31,6 +31,12 @@ def local_track_uri_to_path(uri, media_dir): 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.""" if isinstance(relpath, compat.text_type): diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index afe0b340..124766dd 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -68,6 +68,16 @@ def test_local_uri_to_path_errors(uri): 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'), From 64b5342c51a927b5fa32150d00e26968a6651af0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 8 May 2015 00:48:31 +0200 Subject: [PATCH 188/318] docs: Document mopidy.local.translator --- docs/modules/local.rst | 14 ++++++++++++++ mopidy/local/translator.py | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/modules/local.rst b/docs/modules/local.rst index 014bac2a..395e8802 100644 --- a/docs/modules/local.rst +++ b/docs/modules/local.rst @@ -6,4 +6,18 @@ 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: diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 856e9d4c..6fc53f63 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -38,7 +38,8 @@ def path_to_file_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) From 382aa0a775ec9c766afd24366cd844e00c82820d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 9 May 2015 00:44:16 +0200 Subject: [PATCH 189/318] httpclient: Move to top level module --- docs/changelog.rst | 4 ++-- mopidy/audio/utils.py | 5 ++--- mopidy/{utils/http.py => httpclient.py} | 0 tests/{utils/test_http.py => test_httpclient.py} | 8 ++++---- 4 files changed, 8 insertions(+), 9 deletions(-) rename mopidy/{utils/http.py => httpclient.py} (100%) rename tests/{utils/test_http.py => test_httpclient.py} (84%) diff --git a/docs/changelog.rst b/docs/changelog.rst index ef0c85b4..48ca9ff7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -42,8 +42,8 @@ Models Utils ----- -- Add :func:`mopidy.utils.http.format_proxy` and - :func:`mopidy.utils.http.format_user_agent`. (Part of: :issue:`1156`) +- Add :func:`mopidy.httpclient.format_proxy` and + :func:`mopidy.httpclient.format_user_agent`. (Part of: :issue:`1156`) Internal changes ---------------- diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 6266b64f..3b9ea30f 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -8,9 +8,8 @@ import pygst pygst.require('0.10') import gst # noqa -from mopidy import compat +from mopidy import compat, httpclient from mopidy.models import Album, Artist, Track -from mopidy.utils import http logger = logging.getLogger(__name__) @@ -143,7 +142,7 @@ def setup_proxy(element, config): if not hasattr(element.props, 'proxy') or not config.get('hostname'): return - element.set_property('proxy', http.format_proxy(config, auth=False)) + element.set_property('proxy', httpclient.format_proxy(config, auth=False)) element.set_property('proxy-id', config.get('username')) element.set_property('proxy-pw', config.get('password')) diff --git a/mopidy/utils/http.py b/mopidy/httpclient.py similarity index 100% rename from mopidy/utils/http.py rename to mopidy/httpclient.py diff --git a/tests/utils/test_http.py b/tests/test_httpclient.py similarity index 84% rename from tests/utils/test_http.py rename to tests/test_httpclient.py index 4553dc05..4497ceda 100644 --- a/tests/utils/test_http.py +++ b/tests/test_httpclient.py @@ -4,7 +4,7 @@ import re import pytest -from mopidy.utils import http +from mopidy.utils import httpclient @pytest.mark.parametrize("config,expected", [ @@ -20,12 +20,12 @@ from mopidy.utils import http 'http://user:pass@proxy.lan:80'), ]) def test_format_proxy(config, expected): - assert http.format_proxy(config) == expected + assert httpclient.format_proxy(config) == expected def test_format_proxy_without_auth(): config = {'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'} - formated_proxy = http.format_proxy(config, auth=False) + formated_proxy = httpclient.format_proxy(config, auth=False) assert formated_proxy == 'http://proxy.lan:80' @@ -35,4 +35,4 @@ def test_format_proxy_without_auth(): ('Foo/1.2.3', r'^Foo/1.2.3 Mopidy/[^ ]+ CPython|/[^ ]+$'), ]) def test_format_user_agent(name, expected): - assert re.match(expected, http.format_user_agent(name)) + assert re.match(expected, httpclient.format_user_agent(name)) From bb19e99af56a0fe98175165dc62a2cf6bab686c9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 9 May 2015 00:48:11 +0200 Subject: [PATCH 190/318] docs: Add httpclient to API docs --- docs/api/httpclient.rst | 9 +++++++++ docs/api/index.rst | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 docs/api/httpclient.rst diff --git a/docs/api/httpclient.rst b/docs/api/httpclient.rst new file mode 100644 index 00000000..85e258c3 --- /dev/null +++ b/docs/api/httpclient.rst @@ -0,0 +1,9 @@ +.. _httpclient-helper: + +************************************************ +:mod:`mopidy.httpclient` --- HTTP Client helpers +************************************************ + +.. automodule:: mopidy.httpclient + :synopsis: HTTP Client helpers for Mopidy its Extensions. + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 3a79af5d..d4bd2f61 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -54,6 +54,7 @@ Utilities .. toctree:: - config commands + config + httpclient zeroconf From 95dc30288ccea40290dedb3edd7937da72be2117 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 9 May 2015 00:52:29 +0200 Subject: [PATCH 191/318] httpclient: Fix import in tests --- tests/test_httpclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_httpclient.py b/tests/test_httpclient.py index 4497ceda..63591f80 100644 --- a/tests/test_httpclient.py +++ b/tests/test_httpclient.py @@ -4,7 +4,7 @@ import re import pytest -from mopidy.utils import httpclient +from mopidy import httpclient @pytest.mark.parametrize("config,expected", [ From ea5dff109ec8a8ea1d3b3dd086a2b8242bdf0bcb Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sun, 10 May 2015 20:57:39 +0200 Subject: [PATCH 192/318] m3u: Fix encoding error when saving playlists with non-ASCII track titles. --- docs/changelog.rst | 3 +++ mopidy/m3u/playlists.py | 16 +--------------- mopidy/m3u/translator.py | 16 ++++++++++++++++ tests/m3u/test_playlists.py | 23 +++++++++++++++++++++-- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 00cee7b8..d87b16a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,9 @@ Bug fix release. - Core: Add workaround for playlist providers that do not support creating playlists. (Fixes: :issue:`1162`, PR :issue:`1165`) +- M3U: Fix encoding error when saving playlists with non-ASCII track + titles. (Fixes: :issue:`1175`, PR :issue:`1176`) + v1.0.4 (2015-04-30) =================== diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index c09eccdf..8800e468 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -88,11 +88,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): self._playlists[playlist.uri] = playlist return playlist - def _write_m3u_extinf(self, file_handle, track): - title = track.name.encode('latin-1', 'replace') - runtime = track.length // 1000 if track.length else -1 - file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') - def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): name = self._invalid_filename_chars.sub('|', name.strip()) # make sure we end up with a valid path segment @@ -113,15 +108,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): name, _ = os.path.splitext(os.path.basename(path).decode(encoding)) else: raise ValueError('M3U playlist needs name or URI') - extended = any(track.name for track in playlist.tracks) - - with open(path, 'w') as file_handle: - if extended: - file_handle.write('#EXTM3U\n') - for track in playlist.tracks: - if extended and track.name: - self._write_m3u_extinf(file_handle, track) - file_handle.write(track.uri + '\n') - + translator.save_m3u(path, playlist.tracks, 'latin1') # assert playlist name matches file name/uri return playlist.copy(uri=uri, name=name) diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py index 4eefce9d..a6e006b1 100644 --- a/mopidy/m3u/translator.py +++ b/mopidy/m3u/translator.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import codecs import logging import os import re @@ -108,3 +109,18 @@ def parse_m3u(file_path, media_dir=None): track = Track() return tracks + + +def save_m3u(filename, tracks, encoding='latin1', errors='replace'): + extended = any(track.name for track in tracks) + # codecs.open() always uses binary mode, just being explicit here + with codecs.open(filename, 'wb', encoding, errors) as m3u: + if extended: + m3u.write('#EXTM3U' + os.linesep) + for track in tracks: + if extended and track.name: + m3u.write('#EXTINF:%d,%s%s' % ( + track.length // 1000 if track.length else -1, + track.name, + os.linesep)) + m3u.write(track.uri + os.linesep) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 355aabf5..b7ac827f 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -107,9 +107,28 @@ class M3UPlaylistsProviderTest(unittest.TestCase): path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: - contents = f.read().splitlines() + m3u = f.read().splitlines() + self.assertEqual(['#EXTM3U', '#EXTINF:60,Test', track.uri], m3u) - self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) + def test_latin1_playlist_contents_is_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test\x9f', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + + with open(path, 'rb') as f: + m3u = f.read().splitlines() + self.assertEqual([b'#EXTM3U', b'#EXTINF:60,Test\x9f', track.uri], m3u) + + def test_utf8_playlist_contents_is_replaced_and_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test\u07b4', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + + with open(path, 'rb') as f: + m3u = f.read().splitlines() + self.assertEqual([b'#EXTM3U', b'#EXTINF:60,Test?', track.uri], m3u) def test_playlists_are_loaded_at_startup(self): track = Track(uri='dummy:track:path2') From f814e945d3e62c87c5f86ef5ac37c5feb733b83d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 10 May 2015 21:49:04 +0200 Subject: [PATCH 193/318] tests: Convert ext test to pytests --- tests/test_ext.py | 48 ++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/tests/test_ext.py b/tests/test_ext.py index c58f6b20..7b16df83 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -1,35 +1,41 @@ from __future__ import absolute_import, unicode_literals -import unittest +import pytest from mopidy import config, ext -class ExtensionTest(unittest.TestCase): +@pytest.fixture +def extension(): + return ext.Extension() - def setUp(self): # noqa: N802 - self.ext = ext.Extension() - def test_dist_name_is_none(self): - self.assertIsNone(self.ext.dist_name) +def test_dist_name_is_none(extension): + assert extension.dist_name is None - def test_ext_name_is_none(self): - self.assertIsNone(self.ext.ext_name) - def test_version_is_none(self): - self.assertIsNone(self.ext.version) +def test_ext_name_is_none(extension): + assert extension.ext_name is None - def test_get_default_config_raises_not_implemented(self): - with self.assertRaises(NotImplementedError): - self.ext.get_default_config() - def test_get_config_schema_returns_extension_schema(self): - schema = self.ext.get_config_schema() - self.assertIsInstance(schema['enabled'], config.Boolean) +def test_version_is_none(extension): + assert extension.version is None - def test_validate_environment_does_nothing_by_default(self): - self.assertIsNone(self.ext.validate_environment()) - def test_setup_raises_not_implemented(self): - with self.assertRaises(NotImplementedError): - self.ext.setup(None) +def test_get_default_config_raises_not_implemented(extension): + with pytest.raises(NotImplementedError): + extension.get_default_config() + + +def test_get_config_schema_returns_extension_schema(extension): + schema = extension.get_config_schema() + assert isinstance(schema['enabled'], config.Boolean) + + +def test_validate_environment_does_nothing_by_default(extension): + assert extension.validate_environment() is None + + +def test_setup_raises_not_implemented(extension): + with pytest.raises(NotImplementedError): + extension.setup(None) From c4e18f4218fcf9edba427e8e36c81ec301173d2b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 10 May 2015 22:10:02 +0200 Subject: [PATCH 194/318] ext: Add ext.load_extensions tests and basic error handling --- mopidy/ext.py | 15 ++++++++- tests/test_ext.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 3122611f..37e91a82 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -148,8 +148,21 @@ def load_extensions(): for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): logger.debug('Loading entry point: %s', entry_point) extension_class = entry_point.load(require=False) - extension = extension_class() + + try: + if not issubclass(extension_class, Extension): + continue # TODO: log this + except TypeError: + continue # TODO: log that extension_class is not a class + + try: + extension = extension_class() + except Exception: + continue # TODO: log this + extension.entry_point = entry_point + + # TODO: store: (instance, entry_point, command, schema, defaults) installed_extensions.append(extension) logger.debug( 'Loaded extension: %s %s', extension.dist_name, extension.version) diff --git a/tests/test_ext.py b/tests/test_ext.py index 7b16df83..ab54bc07 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -1,10 +1,14 @@ from __future__ import absolute_import, unicode_literals +import mock + import pytest from mopidy import config, ext +# ext.Extension + @pytest.fixture def extension(): return ext.Extension() @@ -39,3 +43,76 @@ def test_validate_environment_does_nothing_by_default(extension): def test_setup_raises_not_implemented(extension): with pytest.raises(NotImplementedError): extension.setup(None) + + +# ext.load_extensions + +class TestExtension(ext.Extension): + dist_name = 'Mopidy-Foobar' + ext_name = 'foobar' + version = '1.2.3' + + +@mock.patch('pkg_resources.iter_entry_points') +def test_load_extensions_no_extenions(mock_entry_points): + mock_entry_points.return_value = [] + assert [] == ext.load_extensions() + + +@mock.patch('pkg_resources.iter_entry_points') +def test_load_extensions(mock_entry_points): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension + + mock_entry_points.return_value = [mock_entry_point] + + extensions = ext.load_extensions() + assert len(extensions) == 1 + assert isinstance(extensions[0], TestExtension) + + +@mock.patch('pkg_resources.iter_entry_points') +def test_load_extensions_gets_wrong_class(mock_entry_points): + + class WrongClass(object): + pass + + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = WrongClass + + mock_entry_points.return_value = [mock_entry_point] + + assert [] == ext.load_extensions() + + +@mock.patch('pkg_resources.iter_entry_points') +def test_load_extensions_gets_instance(mock_entry_points): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension() + + mock_entry_points.return_value = [mock_entry_point] + + assert [] == ext.load_extensions() + + +@mock.patch('pkg_resources.iter_entry_points') +def test_load_extensions_creating_instance_fails(mock_entry_points): + mock_extension = mock.Mock(spec=ext.Extension) + mock_extension.side_effect = Exception + + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = mock_extension + + mock_entry_points.return_value = [mock_entry_point] + assert [] == ext.load_extensions() + + +@mock.patch('pkg_resources.iter_entry_points') +def test_load_extensions_store_entry_point(mock_entry_points): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension + mock_entry_points.return_value = [mock_entry_point] + + extensions = ext.load_extensions() + assert len(extensions) == 1 + assert extensions[0].entry_point == mock_entry_point From 5937cdc3b250f762472b3c56cd4eb9c3c1c1a1ff Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 10 May 2015 23:15:13 +0200 Subject: [PATCH 195/318] ext: Add tests for validate_extension and handle validate_environment failures --- mopidy/ext.py | 2 ++ tests/test_ext.py | 90 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 37e91a82..625c27a2 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -211,5 +211,7 @@ def validate_extension(extension): logger.info( 'Disabled extension %s: %s', extension.ext_name, ex.message) return False + except Exception: + return False # TODO: log return True diff --git a/tests/test_ext.py b/tests/test_ext.py index ab54bc07..72e1e141 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -2,9 +2,17 @@ from __future__ import absolute_import, unicode_literals import mock +import pkg_resources + import pytest -from mopidy import config, ext +from mopidy import config, exceptions, ext + + +class TestExtension(ext.Extension): + dist_name = 'Mopidy-Foobar' + ext_name = 'foobar' + version = '1.2.3' # ext.Extension @@ -47,11 +55,6 @@ def test_setup_raises_not_implemented(extension): # ext.load_extensions -class TestExtension(ext.Extension): - dist_name = 'Mopidy-Foobar' - ext_name = 'foobar' - version = '1.2.3' - @mock.patch('pkg_resources.iter_entry_points') def test_load_extensions_no_extenions(mock_entry_points): @@ -116,3 +119,78 @@ def test_load_extensions_store_entry_point(mock_entry_points): extensions = ext.load_extensions() assert len(extensions) == 1 assert extensions[0].entry_point == mock_entry_point + + +# ext.validate_extension + +def test_validate_extension_name_mismatch(): + ep = mock.Mock() + ep.name = 'barfoo' + + extension = TestExtension() + extension.entry_point = ep + + assert not ext.validate_extension(extension) + + +def test_validate_extension_distribution_not_found(): + ep = mock.Mock() + ep.name = 'foobar' + ep.require.side_effect = pkg_resources.DistributionNotFound + + extension = TestExtension() + extension.entry_point = ep + + assert not ext.validate_extension(extension) + + +def test_validate_extension_version_conflict(): + ep = mock.Mock() + ep.name = 'foobar' + ep.require.side_effect = pkg_resources.VersionConflict + + extension = TestExtension() + extension.entry_point = ep + + assert not ext.validate_extension(extension) + + +def test_validate_extension_exception(): + ep = mock.Mock() + ep.name = 'foobar' + ep.require.side_effect = Exception + + extension = TestExtension() + extension.entry_point = ep + + # We trust that entry points are well behaved, so exception will bubble. + with pytest.raises(Exception): + assert not ext.validate_extension(extension) + + +def test_validate_extension_instance_validate_env_ext_error(): + ep = mock.Mock() + ep.name = 'foobar' + + extension = TestExtension() + extension.entry_point = ep + + with mock.patch.object(extension, 'validate_environment') as validate: + validate.side_effect = exceptions.ExtensionError('error') + + assert not ext.validate_extension(extension) + validate.assert_called_once_with() + + +def test_validate_extension_instance_validate_env_exception(): + ep = mock.Mock() + ep.name = 'foobar' + + extension = TestExtension() + extension.entry_point = ep + + with mock.patch.object(extension, 'validate_environment') as validate: + validate.side_effect = Exception + + assert not ext.validate_extension(extension) + validate.assert_called_once_with() From 5550785146aeb0e9426936c2fc69360f18c76118 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 11 May 2015 00:28:18 +0200 Subject: [PATCH 196/318] ext: Wrap extension state in a ExtensionData tuple This allows us to do more of the data loading that might fail safely in the mopidy.ext module instead of having things spread all over the place. Note that only minimal changes have been made to __main__ to make things work. Further refactoring should follow. --- mopidy/__main__.py | 23 ++++++---- mopidy/ext.py | 34 ++++++++++---- tests/test_ext.py | 107 +++++++++++++++++++-------------------------- 3 files changed, 86 insertions(+), 78 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 6584146f..4ec0026c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -68,19 +68,20 @@ def main(): installed_extensions = ext.load_extensions() - for extension in installed_extensions: - ext_cmd = extension.get_command() - if ext_cmd: - ext_cmd.set(extension=extension) - root_cmd.add_child(extension.ext_name, ext_cmd) + for data in installed_extensions: + if data.command: + data.command.set(extension=data.command) + root_cmd.add_child(data.extension.ext_name, data.command) args = root_cmd.parse(mopidy_args) create_file_structures_and_config(args, installed_extensions) check_old_locations() + # TODO: make config.load use extension data? or just pass in schema+def config, config_errors = config_lib.load( - args.config_files, installed_extensions, args.config_overrides) + args.config_files, [d.extension for d in installed_extensions], + args.config_overrides) verbosity_level = args.base_verbosity_level if args.verbosity_level: @@ -90,8 +91,11 @@ def main(): extensions = { 'validate': [], 'config': [], 'disabled': [], 'enabled': []} - for extension in installed_extensions: - if not ext.validate_extension(extension): + for data in installed_extensions: + extension = data.extension + + # TODO: factor out all of this to a helper that can be tested + if not ext.validate_extension(data.extension, data.entry_point): config[extension.ext_name] = {'enabled': False} config_errors[extension.ext_name] = { 'enabled': 'extension disabled by self check.'} @@ -109,6 +113,9 @@ def main(): else: extensions['enabled'].append(extension) + # TODO: convert rest of code to use new ExtensionData + installed_extensions = [d.extension for d in installed_extensions] + log_extension_info(installed_extensions, extensions['enabled']) # Config and deps commands are simply special cased for now. diff --git a/mopidy/ext.py b/mopidy/ext.py index 625c27a2..32e06d1c 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -11,6 +11,12 @@ from mopidy import config as config_lib, exceptions logger = logging.getLogger(__name__) +_extension_data_fields = ['extension', 'entry_point', 'config_schema', + 'config_defaults', 'command'] + +ExtensionData = collections.namedtuple('ExtensionData', _extension_data_fields) + + class Extension(object): """Base class for Mopidy extensions""" @@ -149,6 +155,8 @@ def load_extensions(): logger.debug('Loading entry point: %s', entry_point) extension_class = entry_point.load(require=False) + # TODO: start using _extension_error_handling(...) pattern + try: if not issubclass(extension_class, Extension): continue # TODO: log this @@ -160,19 +168,26 @@ def load_extensions(): except Exception: continue # TODO: log this - extension.entry_point = entry_point + # TODO: handle exceptions and validate result... + config_schema = extension.get_config_schema() + default_config = extension.get_default_config() + command = extension.get_command() + + installed_extensions.append(ExtensionData( + extension, entry_point, config_schema, default_config, command)) + + # TODO: call validate_extension here? + # TODO: do basic config tests like schema contains enabled? - # TODO: store: (instance, entry_point, command, schema, defaults) - installed_extensions.append(extension) logger.debug( 'Loaded extension: %s %s', extension.dist_name, extension.version) - names = (e.ext_name for e in installed_extensions) + names = (ed.extension.ext_name for ed in installed_extensions) logger.debug('Discovered extensions: %s', ', '.join(names)) return installed_extensions -def validate_extension(extension): +def validate_extension(extension, entry_point): """Verify extension's dependencies and environment. :param extensions: an extension to check @@ -181,15 +196,15 @@ def validate_extension(extension): logger.debug('Validating extension: %s', extension.ext_name) - if extension.ext_name != extension.entry_point.name: + if extension.ext_name != entry_point.name: logger.warning( 'Disabled extension %(ep)s: entry point name (%(ep)s) ' 'does not match extension name (%(ext)s)', - {'ep': extension.entry_point.name, 'ext': extension.ext_name}) + {'ep': entry_point.name, 'ext': extension.ext_name}) return False try: - extension.entry_point.require() + entry_point.require() except pkg_resources.DistributionNotFound as ex: logger.info( 'Disabled extension %s: Dependency %s not found', @@ -202,7 +217,8 @@ def validate_extension(extension): 'Disabled extension %s: %s required, but found %s at %s', extension.ext_name, required, found, found.location) else: - logger.info('Disabled extension %s: %s', extension.ext_name, ex) + logger.info( + 'Disabled extension %s: %s', extension.ext_name, ex) return False try: diff --git a/tests/test_ext.py b/tests/test_ext.py index 72e1e141..d5dad4b1 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -8,12 +8,20 @@ import pytest from mopidy import config, exceptions, ext +from tests import IsA, any_unicode + class TestExtension(ext.Extension): dist_name = 'Mopidy-Foobar' ext_name = 'foobar' version = '1.2.3' + def get_default_config(self): + return '[foobar]\nenabled = true' + + +any_testextension = IsA(TestExtension) + # ext.Extension @@ -69,9 +77,11 @@ def test_load_extensions(mock_entry_points): mock_entry_points.return_value = [mock_entry_point] - extensions = ext.load_extensions() - assert len(extensions) == 1 - assert isinstance(extensions[0], TestExtension) + expected = ext.ExtensionData( + any_testextension, mock_entry_point, IsA(config.ConfigSchema), + any_unicode, None) + + assert ext.load_extensions() == [expected] @mock.patch('pkg_resources.iter_entry_points') @@ -110,87 +120,62 @@ def test_load_extensions_creating_instance_fails(mock_entry_points): assert [] == ext.load_extensions() -@mock.patch('pkg_resources.iter_entry_points') -def test_load_extensions_store_entry_point(mock_entry_points): - mock_entry_point = mock.Mock() - mock_entry_point.load.return_value = TestExtension - mock_entry_points.return_value = [mock_entry_point] - - extensions = ext.load_extensions() - assert len(extensions) == 1 - assert extensions[0].entry_point == mock_entry_point - - # ext.validate_extension -def test_validate_extension_name_mismatch(): - ep = mock.Mock() - ep.name = 'barfoo' - +@pytest.fixture +def ext_data(): extension = TestExtension() - extension.entry_point = ep - assert not ext.validate_extension(extension) + entry_point = mock.Mock() + entry_point.name = extension.ext_name + + schema = extension.get_config_schema() + defaults = extension.get_default_config() + command = extension.get_command() + + return ext.ExtensionData(extension, entry_point, schema, defaults, command) -def test_validate_extension_distribution_not_found(): - ep = mock.Mock() - ep.name = 'foobar' - ep.require.side_effect = pkg_resources.DistributionNotFound - - extension = TestExtension() - extension.entry_point = ep - - assert not ext.validate_extension(extension) +def test_validate_extension_name_mismatch(ext_data): + ext_data.entry_point.name = 'barfoo' + assert not ext.validate_extension(ext_data.extension, ext_data.entry_point) -def test_validate_extension_version_conflict(): - ep = mock.Mock() - ep.name = 'foobar' - ep.require.side_effect = pkg_resources.VersionConflict - - extension = TestExtension() - extension.entry_point = ep - - assert not ext.validate_extension(extension) +def test_validate_extension_distribution_not_found(ext_data): + error = pkg_resources.DistributionNotFound + ext_data.entry_point.require.side_effect = error + assert not ext.validate_extension(ext_data.extension, ext_data.entry_point) -def test_validate_extension_exception(): - ep = mock.Mock() - ep.name = 'foobar' - ep.require.side_effect = Exception +def test_validate_extension_version_conflict(ext_data): + ext_data.entry_point.require.side_effect = pkg_resources.VersionConflict + assert not ext.validate_extension(ext_data.extension, ext_data.entry_point) - extension = TestExtension() - extension.entry_point = ep + +def test_validate_extension_exception(ext_data): + ext_data.entry_point.require.side_effect = Exception # We trust that entry points are well behaved, so exception will bubble. with pytest.raises(Exception): - assert not ext.validate_extension(extension) + assert not ext.validate_extension( + ext_data.extension, ext_data.entry_point) -def test_validate_extension_instance_validate_env_ext_error(): - ep = mock.Mock() - ep.name = 'foobar' - - extension = TestExtension() - extension.entry_point = ep - +def test_validate_extension_instance_validate_env_ext_error(ext_data): + extension = ext_data.extension with mock.patch.object(extension, 'validate_environment') as validate: validate.side_effect = exceptions.ExtensionError('error') - assert not ext.validate_extension(extension) + assert not ext.validate_extension( + ext_data.extension, ext_data.entry_point) validate.assert_called_once_with() -def test_validate_extension_instance_validate_env_exception(): - ep = mock.Mock() - ep.name = 'foobar' - - extension = TestExtension() - extension.entry_point = ep - +def test_validate_extension_instance_validate_env_exception(ext_data): + extension = ext_data.extension with mock.patch.object(extension, 'validate_environment') as validate: validate.side_effect = Exception - assert not ext.validate_extension(extension) + assert not ext.validate_extension( + ext_data.extension, ext_data.entry_point) validate.assert_called_once_with() From d302851ebeb0017ec6f48baa7f7a79f59229b45e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 09:53:31 +0200 Subject: [PATCH 197/318] httpclient: Tune docstrings --- mopidy/httpclient.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/httpclient.py b/mopidy/httpclient.py index d0b3cb4c..54f39ca3 100644 --- a/mopidy/httpclient.py +++ b/mopidy/httpclient.py @@ -14,7 +14,7 @@ def format_proxy(proxy_config, auth=True): :class:`None` depending on the proxy config provided. You can also opt out of getting the basic auth by setting ``auth`` to - :type:`False`. + :class:`False`. """ if not proxy_config.get('hostname'): return None @@ -37,8 +37,8 @@ def format_proxy(proxy_config, auth=True): def format_user_agent(name=None): """Construct a User-Agent suitable for use in client code. - This will identify use by the provided name (which should be - ``dist_name/version``), Mopidy version and Python version. + This will identify use by the provided ``name`` (which should be on the + format ``dist_name/version``), Mopidy version and Python version. """ parts = ['Mopidy/%s' % (mopidy.__version__), '%s/%s' % (platform.python_implementation(), From 8434a22c83871385ca4e5bcacd4bb6118e898450 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 11 May 2015 20:53:50 +0200 Subject: [PATCH 198/318] ext: Switch to using fixtures for mocking --- tests/test_ext.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/tests/test_ext.py b/tests/test_ext.py index d5dad4b1..4d9a4638 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -63,19 +63,25 @@ def test_setup_raises_not_implemented(extension): # ext.load_extensions +@pytest.fixture +def iter_entry_points_mock(request): + patcher = mock.patch('pkg_resources.iter_entry_points') + iter_entry_points = patcher.start() + iter_entry_points.return_value = [] + request.addfinalizer(patcher.stop) + return iter_entry_points -@mock.patch('pkg_resources.iter_entry_points') -def test_load_extensions_no_extenions(mock_entry_points): - mock_entry_points.return_value = [] + +def test_load_extensions_no_extenions(iter_entry_points_mock): + iter_entry_points_mock.return_value = [] assert [] == ext.load_extensions() -@mock.patch('pkg_resources.iter_entry_points') -def test_load_extensions(mock_entry_points): +def test_load_extensions(iter_entry_points_mock): mock_entry_point = mock.Mock() mock_entry_point.load.return_value = TestExtension - mock_entry_points.return_value = [mock_entry_point] + iter_entry_points_mock.return_value = [mock_entry_point] expected = ext.ExtensionData( any_testextension, mock_entry_point, IsA(config.ConfigSchema), @@ -84,8 +90,7 @@ def test_load_extensions(mock_entry_points): assert ext.load_extensions() == [expected] -@mock.patch('pkg_resources.iter_entry_points') -def test_load_extensions_gets_wrong_class(mock_entry_points): +def test_load_extensions_gets_wrong_class(iter_entry_points_mock): class WrongClass(object): pass @@ -93,30 +98,29 @@ def test_load_extensions_gets_wrong_class(mock_entry_points): mock_entry_point = mock.Mock() mock_entry_point.load.return_value = WrongClass - mock_entry_points.return_value = [mock_entry_point] + iter_entry_points_mock.return_value = [mock_entry_point] assert [] == ext.load_extensions() -@mock.patch('pkg_resources.iter_entry_points') -def test_load_extensions_gets_instance(mock_entry_points): +def test_load_extensions_gets_instance(iter_entry_points_mock): mock_entry_point = mock.Mock() mock_entry_point.load.return_value = TestExtension() - mock_entry_points.return_value = [mock_entry_point] + iter_entry_points_mock.return_value = [mock_entry_point] assert [] == ext.load_extensions() -@mock.patch('pkg_resources.iter_entry_points') -def test_load_extensions_creating_instance_fails(mock_entry_points): +def test_load_extensions_creating_instance_fails(iter_entry_points_mock): mock_extension = mock.Mock(spec=ext.Extension) mock_extension.side_effect = Exception mock_entry_point = mock.Mock() mock_entry_point.load.return_value = mock_extension - mock_entry_points.return_value = [mock_entry_point] + iter_entry_points_mock.return_value = [mock_entry_point] + assert [] == ext.load_extensions() From 8ed9e5f1e07a492ba45f698c5083bc9dee16edc3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 11 May 2015 21:20:37 +0200 Subject: [PATCH 199/318] ext: Catch exceptions in extension helpers --- mopidy/ext.py | 10 ++++++---- tests/test_ext.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 32e06d1c..a59eeae1 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -165,13 +165,15 @@ def load_extensions(): try: extension = extension_class() + config_schema = extension.get_config_schema() + default_config = extension.get_default_config() except Exception: continue # TODO: log this - # TODO: handle exceptions and validate result... - config_schema = extension.get_config_schema() - default_config = extension.get_default_config() - command = extension.get_command() + try: + command = extension.get_command() + except Exception: + command = None # TODO: log this. installed_extensions.append(ExtensionData( extension, entry_point, config_schema, default_config, command)) diff --git a/tests/test_ext.py b/tests/test_ext.py index 4d9a4638..f6f31c21 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -124,6 +124,46 @@ def test_load_extensions_creating_instance_fails(iter_entry_points_mock): assert [] == ext.load_extensions() +def test_load_extensions_get_config_schema_fails(iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + with mock.patch.object(TestExtension, 'get_config_schema') as get_schema: + get_schema.side_effect = Exception + assert [] == ext.load_extensions() + get_schema.assert_called_once_with() + + +def test_load_extensions_get_default_config_fails(iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + with mock.patch.object(TestExtension, 'get_default_config') as get_default: + get_default.side_effect = Exception + assert [] == ext.load_extensions() + get_default.assert_called_once_with() + + +def test_load_extensions_get_command_fails(iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + expected = ext.ExtensionData( + any_testextension, mock_entry_point, IsA(config.ConfigSchema), + any_unicode, None) + + with mock.patch.object(TestExtension, 'get_command') as get_command: + get_command.side_effect = Exception + assert [expected] == ext.load_extensions() + get_command.assert_called_once_with() + + # ext.validate_extension @pytest.fixture From 4566ddd9ae2b9619b046d4686d13e30c09ff8dfd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 11 May 2015 21:29:03 +0200 Subject: [PATCH 200/318] ext: Add exception logging to extension loading --- mopidy/ext.py | 19 +++++++------------ tests/test_ext.py | 6 +----- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index a59eeae1..96e5d5e1 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -155,32 +155,27 @@ def load_extensions(): logger.debug('Loading entry point: %s', entry_point) extension_class = entry_point.load(require=False) - # TODO: start using _extension_error_handling(...) pattern - try: if not issubclass(extension_class, Extension): - continue # TODO: log this + raise TypeError # issubclass raises TypeError on non-class except TypeError: - continue # TODO: log that extension_class is not a class + logger.error('Entry point %s did not contain a valid extension' + 'class: %r', entry_point.name, extension_class) + continue try: extension = extension_class() config_schema = extension.get_config_schema() default_config = extension.get_default_config() - except Exception: - continue # TODO: log this - - try: command = extension.get_command() except Exception: - command = None # TODO: log this. + logger.exception('Setup of extension from entry point %s failed, ' + 'ignoring extension.', entry_point.name) + continue installed_extensions.append(ExtensionData( extension, entry_point, config_schema, default_config, command)) - # TODO: call validate_extension here? - # TODO: do basic config tests like schema contains enabled? - logger.debug( 'Loaded extension: %s %s', extension.dist_name, extension.version) diff --git a/tests/test_ext.py b/tests/test_ext.py index f6f31c21..b4fa8b9e 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -154,13 +154,9 @@ def test_load_extensions_get_command_fails(iter_entry_points_mock): iter_entry_points_mock.return_value = [mock_entry_point] - expected = ext.ExtensionData( - any_testextension, mock_entry_point, IsA(config.ConfigSchema), - any_unicode, None) - with mock.patch.object(TestExtension, 'get_command') as get_command: get_command.side_effect = Exception - assert [expected] == ext.load_extensions() + assert [] == ext.load_extensions() get_command.assert_called_once_with() From d37b76f6c94a781f5b48a0e6719e08a6b69a6513 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:35:11 +0200 Subject: [PATCH 201/318] docs: Remove PMix from MPD client list It is no longer available on Google Play, and we didn't recommend it 2.5y ago when it was. --- docs/clients/mpd.rst | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 91d0f8db..9ff888bc 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -234,21 +234,6 @@ You can get `Droid MPD Client from Google Play In conclusion, not a client we can recommend. -PMix ----- - -Test date: - 2012-11-06 -Tested version: - 0.4.0 (released 2010-03-06) - -You can get `PMix from Google Play -`_. - -PMix haven't been updated for 2.5 years, and has less working features than -it's fork MPDroid. Ignore PMix and use MPDroid instead. - - MPD Remote ---------- From c95b3016965c6c1f4f1b49ad697435938caccb5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:36:24 +0200 Subject: [PATCH 202/318] docs: Remove MPD Remote from MPD client list It looked terrible 2.5y ago and I didn't care to test it. It has seen no updates since. --- docs/clients/mpd.rst | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 9ff888bc..760d2f6e 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -234,21 +234,6 @@ You can get `Droid MPD Client from Google Play In conclusion, not a client we can recommend. -MPD Remote ----------- - -Test date: - 2012-11-06 -Tested version: - 1.0 (released 2012-05-01) - -You can get `MPD Remote from Google Play -`_. - -This app looks terrible in the screen shots, got just 100+ downloads, and got a -terrible rating. I honestly didn't take the time to test it. - - .. _ios_mpd_clients: iOS clients From 0b3b17e3a66e6ae6a1771a5ee4a92d594ee5f1b8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:37:51 +0200 Subject: [PATCH 203/318] docs: Remove bitMPC from MPD client list It looked bad and only worked on Android 2.x when tested 2.5y ago. It has seen no updates since 2010. --- docs/clients/mpd.rst | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 760d2f6e..4d7a8881 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -170,37 +170,6 @@ You can get `MPDroid from Google Play MPDroid is a good MPD client, and really the only one we can recommend. -BitMPC ------- - -Test date: - 2012-11-06 -Tested version: - 1.0.0 (released 2010-04-12) - -You can get `BitMPC from Google Play -`_. - -- The user interface lacks some finishing touches. E.g. you can't enter a - hostname for the server. Only IPv4 addresses are allowed. - -- When we last tested the same version of BitMPC using Android 2.1: - - - All features exercised in the test procedure worked. - - - BitMPC lacked support for single mode and consume mode. - - - BitMPC crashed if Mopidy was killed or crashed. - -- When we tried to test using Android 4.1.1, BitMPC started and connected to - Mopidy without problems, but the app crashed as soon as we fired off our - search, and continued to crash on startup after that. - -In conclusion, BitMPC is usable if you got an older Android phone and don't -care about looks. For newer Android versions, BitMPC will probably not work as -it hasn't been maintained for 2.5 years. - - Droid MPD Client ---------------- From 0273b14c70f7ca63cfaf020a55225172134aad0b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:39:44 +0200 Subject: [PATCH 204/318] docs: Remove Droid MPD Client from MPD client list We couldn't recommend it 2.5y ago, and it has seen no updates since. --- docs/clients/mpd.rst | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 4d7a8881..36af7b84 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -170,39 +170,6 @@ You can get `MPDroid from Google Play MPDroid is a good MPD client, and really the only one we can recommend. -Droid MPD Client ----------------- - -Test date: - 2012-11-06 -Tested version: - 1.4.0 (released 2011-12-20) - -You can get `Droid MPD Client from Google Play -`_. - -- No intutive way to ask the app to connect to the server after adding the - server hostname to the settings. - -- To find the search functionality, you have to select the menu, - then "Playlist manager", then the search tab. I do not understand why search - is hidden inside "Playlist manager". - -- The tabs "Artists" and "Albums" did not contain anything, and did not cause - any requests. - -- The tab "Folders" showed a spinner and said "Updating data..." but did not - send any requests. - -- Searching for "foo" did nothing. No request was sent to the server. - -- Droid MPD client does not support single mode or consume mode. - -- Not able to complete the test procedure, due to the above problems. - -In conclusion, not a client we can recommend. - - .. _ios_mpd_clients: iOS clients From b03d3a8a1c61f3f5074501da1910d0f6337d8441 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:43:25 +0200 Subject: [PATCH 205/318] docs: Include 'MPD' in the subsection headers --- docs/clients/mpd.rst | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 36af7b84..ed1df581 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -41,9 +41,8 @@ completeness of clients: #. Kill Mopidy and confirm that the app handles it without crashing - -Console clients -=============== +MPD console clients +=================== ncmpcpp ------- @@ -83,8 +82,8 @@ A command line client. Version 0.16 and upwards seems to work nicely with Mopidy. -Graphical clients -================= +MPD graphical clients +===================== GMPC ---- @@ -132,8 +131,8 @@ client for OS X. It is unmaintained, but generally works well with Mopidy. .. _android_mpd_clients: -Android clients -=============== +MPD Android clients +=================== We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test @@ -172,8 +171,8 @@ MPDroid is a good MPD client, and really the only one we can recommend. .. _ios_mpd_clients: -iOS clients -=========== +MPD iOS clients +=============== MPoD ---- @@ -236,8 +235,8 @@ purchased from `MPaD at iTunes Store .. _mpd-web-clients: -Web clients -=========== +MPD web clients +=============== The following web clients use the MPD protocol to communicate with Mopidy. For other web clients, see :ref:`http-clients`. From 4ede30436a32e65461053cf686c1f3606e5389a2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:48:19 +0200 Subject: [PATCH 206/318] docs: Remove MPD client test procedure and outdated results --- docs/clients/mpd.rst | 86 -------------------------------------------- 1 file changed, 86 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index ed1df581..fe7ef21d 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -12,35 +12,6 @@ http://mpd.wikia.com/wiki/Clients. :local: -Test procedure -============== - -In some cases, we've used the following test procedure to compare the feature -completeness of clients: - -#. Connect to Mopidy -#. Search for "foo", with search type "any" if it can be selected -#. Add "The Pretender" from the search results to the current playlist -#. Start playback -#. Pause and resume playback -#. Adjust volume -#. Find a playlist and append it to the current playlist -#. Skip to next track -#. Skip to previous track -#. Select the last track from the current playlist -#. Turn on repeat mode -#. Seek to 10 seconds or so before the end of the track -#. Wait for the end of the track and confirm that playback continues at the - start of the playlist -#. Turn off repeat mode -#. Turn on random mode -#. Skip to next track and confirm that it random mode works -#. Turn off random mode -#. Stop playback -#. Check if the app got support for single mode and consume mode -#. Kill Mopidy and confirm that the app handles it without crashing - - MPD console clients =================== @@ -134,19 +105,9 @@ client for OS X. It is unmaintained, but generally works well with Mopidy. MPD Android clients =================== -We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 -on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test -procedure. - - MPDroid ------- -Test date: - 2012-11-06 -Tested version: - 1.03.1 (released 2012-10-16) - .. image:: mpd-client-mpdroid.jpg :width: 288 :height: 512 @@ -154,18 +115,6 @@ Tested version: You can get `MPDroid from Google Play `_. -- MPDroid started out as a fork of PMix, and is now much better. - -- MPDroid's user interface looks nice. - -- Everything in the test procedure works. - -- In contrast to all other Android clients, MPDroid does support single mode or - consume mode. - -- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to - try to reconnect. - MPDroid is a good MPD client, and really the only one we can recommend. @@ -177,11 +126,6 @@ MPD iOS clients MPoD ---- -Test date: - 2012-11-06 -Tested version: - 1.7.1 - .. image:: mpd-client-mpod.jpg :width: 320 :height: 480 @@ -190,26 +134,10 @@ The `MPoD `_ iPhone/iPod Touch app can be installed from `MPoD at iTunes Store `_. -- The user interface looks nice. - -- All features exercised in the test procedure worked with MPaD, except seek, - which I didn't figure out to do. - -- Search only works in the "Browse" tab, and not under in the "Artist", - "Album", or "Song" tabs. For the tabs where search doesn't work, no queries - are sent to Mopidy when searching. - -- Single mode and consume mode is supported. - MPaD ---- -Test date: - 2012-11-06 -Tested version: - 1.7.1 - .. image:: mpd-client-mpad.jpg :width: 480 :height: 360 @@ -218,20 +146,6 @@ The `MPaD `_ iPad app can be purchased from `MPaD at iTunes Store `_ -- The user interface looks nice, though I would like to be able to view the - current playlist in the large part of the split view. - -- All features exercised in the test procedure worked with MPaD. - -- Search only works in the "Browse" tab, and not under in the "Artist", - "Album", or "Song" tabs. For the tabs where search doesn't work, no queries - are sent to Mopidy when searching. - -- Single mode and consume mode is supported. - -- The server menu can be very slow top open, and there is no visible feedback - when waiting for the connection to a server to succeed. - .. _mpd-web-clients: From 8b6553ec1649987ee27915e5f504536ddf38d12a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 11 May 2015 22:30:50 +0200 Subject: [PATCH 207/318] ext: Update validate_extension to validate_extension_data Adds more checks to catch extension errors and importantly tests for exercise these code paths. --- mopidy/__main__.py | 2 +- mopidy/ext.py | 45 ++++++++++++++++++++++++++++++++++----------- tests/test_ext.py | 43 +++++++++++++++++++++++++++++++++---------- 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 4ec0026c..69ba5370 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -95,7 +95,7 @@ def main(): extension = data.extension # TODO: factor out all of this to a helper that can be tested - if not ext.validate_extension(data.extension, data.entry_point): + if not ext.validate_extension_data(data): config[extension.ext_name] = {'enabled': False} config_errors[extension.ext_name] = { 'enabled': 'extension disabled by self check.'} diff --git a/mopidy/ext.py b/mopidy/ext.py index 96e5d5e1..ab35008a 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -184,47 +184,70 @@ def load_extensions(): return installed_extensions -def validate_extension(extension, entry_point): +def validate_extension_data(data): """Verify extension's dependencies and environment. :param extensions: an extension to check :returns: if extension should be run """ - logger.debug('Validating extension: %s', extension.ext_name) + logger.debug('Validating extension: %s', data.extension.ext_name) - if extension.ext_name != entry_point.name: + if data.extension.ext_name != data.entry_point.name: logger.warning( 'Disabled extension %(ep)s: entry point name (%(ep)s) ' 'does not match extension name (%(ext)s)', - {'ep': entry_point.name, 'ext': extension.ext_name}) + {'ep': data.entry_point.name, 'ext': data.extension.ext_name}) return False try: - entry_point.require() + data.entry_point.require() except pkg_resources.DistributionNotFound as ex: logger.info( 'Disabled extension %s: Dependency %s not found', - extension.ext_name, ex) + data.extension.ext_name, ex) return False except pkg_resources.VersionConflict as ex: if len(ex.args) == 2: found, required = ex.args logger.info( 'Disabled extension %s: %s required, but found %s at %s', - extension.ext_name, required, found, found.location) + data.extension.ext_name, required, found, found.location) else: logger.info( - 'Disabled extension %s: %s', extension.ext_name, ex) + 'Disabled extension %s: %s', data.extension.ext_name, ex) return False try: - extension.validate_environment() + data.extension.validate_environment() except exceptions.ExtensionError as ex: logger.info( - 'Disabled extension %s: %s', extension.ext_name, ex.message) + 'Disabled extension %s: %s', data.extension.ext_name, ex.message) return False except Exception: - return False # TODO: log + logger.exception('Validating extension %s failed with an exception.', + data.extension.ext_name) + return False + + if not data.config_schema: + logger.error('Extension %s does not have a config schema, disabling.', + data.extension.ext_name) + return False + elif not isinstance(data.config_schema.get('enabled'), config_lib.Boolean): + logger.error('Extension %s does not have the required "enabled" config' + ' option, disabling.', data.extension.ext_name) + return False + + for key, value in data.config_schema.items(): + if not isinstance(value, config_lib.ConfigValue): + logger.error('Extension %s config schema contains an invalid value' + ' for the option "%s", disabling.', + data.extension.ext_name, key) + return False + + if not data.config_defaults: + logger.error('Extension %s does not have a default config, disabling.', + data.extension.ext_name) + return False return True diff --git a/tests/test_ext.py b/tests/test_ext.py index b4fa8b9e..7fe6dba4 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -160,7 +160,7 @@ def test_load_extensions_get_command_fails(iter_entry_points_mock): get_command.assert_called_once_with() -# ext.validate_extension +# ext.validate_extension_data @pytest.fixture def ext_data(): @@ -178,18 +178,18 @@ def ext_data(): def test_validate_extension_name_mismatch(ext_data): ext_data.entry_point.name = 'barfoo' - assert not ext.validate_extension(ext_data.extension, ext_data.entry_point) + assert not ext.validate_extension_data(ext_data) def test_validate_extension_distribution_not_found(ext_data): error = pkg_resources.DistributionNotFound ext_data.entry_point.require.side_effect = error - assert not ext.validate_extension(ext_data.extension, ext_data.entry_point) + assert not ext.validate_extension_data(ext_data) def test_validate_extension_version_conflict(ext_data): ext_data.entry_point.require.side_effect = pkg_resources.VersionConflict - assert not ext.validate_extension(ext_data.extension, ext_data.entry_point) + assert not ext.validate_extension_data(ext_data) def test_validate_extension_exception(ext_data): @@ -197,8 +197,7 @@ def test_validate_extension_exception(ext_data): # We trust that entry points are well behaved, so exception will bubble. with pytest.raises(Exception): - assert not ext.validate_extension( - ext_data.extension, ext_data.entry_point) + assert not ext.validate_extension_data(ext_data) def test_validate_extension_instance_validate_env_ext_error(ext_data): @@ -206,8 +205,7 @@ def test_validate_extension_instance_validate_env_ext_error(ext_data): with mock.patch.object(extension, 'validate_environment') as validate: validate.side_effect = exceptions.ExtensionError('error') - assert not ext.validate_extension( - ext_data.extension, ext_data.entry_point) + assert not ext.validate_extension_data(ext_data) validate.assert_called_once_with() @@ -216,6 +214,31 @@ def test_validate_extension_instance_validate_env_exception(ext_data): with mock.patch.object(extension, 'validate_environment') as validate: validate.side_effect = Exception - assert not ext.validate_extension( - ext_data.extension, ext_data.entry_point) + assert not ext.validate_extension_data(ext_data) validate.assert_called_once_with() + + +def test_validate_extension_with_missing_schema(ext_data): + ext_data = ext_data._replace(config_schema=None) + assert not ext.validate_extension_data(ext_data) + + +def test_validate_extension_with_schema_that_is_missing_enabled(ext_data): + del ext_data.config_schema['enabled'] + ext_data.config_schema['baz'] = config.String() + assert not ext.validate_extension_data(ext_data) + + +def test_validate_extension_with_schema_with_wrong_types(ext_data): + ext_data.config_schema['enabled'] = 123 + assert not ext.validate_extension_data(ext_data) + + +def test_validate_extension_with_schema_with_invalid_type(ext_data): + ext_data.config_schema['baz'] = 123 + assert not ext.validate_extension_data(ext_data) + + +def test_validate_extension_with_no_defaults(ext_data): + ext_data = ext_data._replace(config_defaults=None) + assert not ext.validate_extension_data(ext_data) From dbc3100e9cfa994fb8a87570c089d08f298b1999 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 11 May 2015 22:47:13 +0200 Subject: [PATCH 208/318] main: Update to use extension_data structure Updated config and __main__ code to use the new wrapper format and pre-fetched values. --- mopidy/__main__.py | 25 ++++++++++++------------- mopidy/commands.py | 4 ++-- mopidy/config/__init__.py | 14 +++++--------- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 69ba5370..9e1e1c94 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -66,21 +66,22 @@ def main(): root_cmd.add_child('config', config_cmd) root_cmd.add_child('deps', deps_cmd) - installed_extensions = ext.load_extensions() + extensions_data = ext.load_extensions() - for data in installed_extensions: - if data.command: + for data in extensions_data: + if data.command: # TODO: check isinstance? data.command.set(extension=data.command) root_cmd.add_child(data.extension.ext_name, data.command) args = root_cmd.parse(mopidy_args) - create_file_structures_and_config(args, installed_extensions) + create_file_structures_and_config(args, extensions_data) check_old_locations() - # TODO: make config.load use extension data? or just pass in schema+def config, config_errors = config_lib.load( - args.config_files, [d.extension for d in installed_extensions], + args.config_files, + [d.config_schema for d in extensions_data], + [d.config_defaults for d in extensions_data], args.config_overrides) verbosity_level = args.base_verbosity_level @@ -91,7 +92,7 @@ def main(): extensions = { 'validate': [], 'config': [], 'disabled': [], 'enabled': []} - for data in installed_extensions: + for data in extensions_data: extension = data.extension # TODO: factor out all of this to a helper that can be tested @@ -113,15 +114,13 @@ def main(): else: extensions['enabled'].append(extension) - # TODO: convert rest of code to use new ExtensionData - installed_extensions = [d.extension for d in installed_extensions] - - log_extension_info(installed_extensions, extensions['enabled']) + log_extension_info([d.extension for d in extensions_data], + extensions['enabled']) # Config and deps commands are simply special cased for now. if args.command == config_cmd: - return args.command.run( - config, config_errors, installed_extensions) + schemas = [d.config_schema for d in extensions_data] + return args.command.run(config, config_errors, schemas) elif args.command == deps_cmd: return args.command.run() diff --git a/mopidy/commands.py b/mopidy/commands.py index 29564779..24acfb7d 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -415,8 +415,8 @@ class ConfigCommand(Command): super(ConfigCommand, self).__init__() self.set(base_verbosity_level=-1) - def run(self, config, errors, extensions): - print(config_lib.format(config, extensions, errors)) + def run(self, config, errors, schemas): + print(config_lib.format(config, schemas, errors)) return 0 diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index fc6dcb60..3f1f978c 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -65,24 +65,20 @@ def read(config_file): return filehandle.read() -def load(files, extensions, overrides): - # Helper to get configs, as the rest of our config system should not need - # to know about extensions. +def load(files, ext_schemas, ext_defaults, overrides): config_dir = os.path.dirname(__file__) defaults = [read(os.path.join(config_dir, 'default.conf'))] - defaults.extend(e.get_default_config() for e in extensions) + defaults.extend(ext_defaults) raw_config = _load(files, defaults, keyring.fetch() + (overrides or [])) schemas = _schemas[:] - schemas.extend(e.get_config_schema() for e in extensions) + schemas.extend(ext_schemas) return _validate(raw_config, schemas) -def format(config, extensions, comments=None, display=True): - # Helper to format configs, as the rest of our config system should not - # need to know about extensions. +def format(config, ext_schemas, comments=None, display=True): schemas = _schemas[:] - schemas.extend(e.get_config_schema() for e in extensions) + schemas.extend(ext_schemas) return _format(config, comments or {}, schemas, display, False) From d630a97bc1b0240cc41dbf742414fa2b7fc60b79 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 12 May 2015 22:00:31 +0200 Subject: [PATCH 209/318] ext: Refactor tests based on review comments --- tests/test_ext.py | 293 +++++++++++++++++++++------------------------- 1 file changed, 136 insertions(+), 157 deletions(-) diff --git a/tests/test_ext.py b/tests/test_ext.py index 7fe6dba4..748aebb3 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -23,222 +23,201 @@ class TestExtension(ext.Extension): any_testextension = IsA(TestExtension) -# ext.Extension +class ExtensionTest(object): -@pytest.fixture -def extension(): - return ext.Extension() + @pytest.fixture + def extension(self): + return ext.Extension() + def test_dist_name_is_none(self, extension): + assert extension.dist_name is None -def test_dist_name_is_none(extension): - assert extension.dist_name is None + def test_ext_name_is_none(self, extension): + assert extension.ext_name is None + def test_version_is_none(self, extension): + assert extension.version is None -def test_ext_name_is_none(extension): - assert extension.ext_name is None + def test_get_default_config_raises_not_implemented(self, extension): + with pytest.raises(NotImplementedError): + extension.get_default_config() + def test_get_config_schema_returns_extension_schema(self, extension): + schema = extension.get_config_schema() + assert isinstance(schema['enabled'], config.Boolean) -def test_version_is_none(extension): - assert extension.version is None + def test_validate_environment_does_nothing_by_default(self, extension): + assert extension.validate_environment() is None + def test_setup_raises_not_implemented(self, extension): + with pytest.raises(NotImplementedError): + extension.setup(None) -def test_get_default_config_raises_not_implemented(extension): - with pytest.raises(NotImplementedError): - extension.get_default_config() +class LoadExtensionsTest(object): -def test_get_config_schema_returns_extension_schema(extension): - schema = extension.get_config_schema() - assert isinstance(schema['enabled'], config.Boolean) + @pytest.yield_fixture + def iter_entry_points_mock(self, request): + patcher = mock.patch('pkg_resources.iter_entry_points') + iter_entry_points = patcher.start() + iter_entry_points.return_value = [] + yield iter_entry_points + patcher.stop() + def test_no_extensions(self, iter_entry_points_mock): + iter_entry_points_mock.return_value = [] + assert ext.load_extensions() == [] -def test_validate_environment_does_nothing_by_default(extension): - assert extension.validate_environment() is None + def test_load_extensions(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension + iter_entry_points_mock.return_value = [mock_entry_point] -def test_setup_raises_not_implemented(extension): - with pytest.raises(NotImplementedError): - extension.setup(None) + expected = ext.ExtensionData( + any_testextension, mock_entry_point, IsA(config.ConfigSchema), + any_unicode, None) + assert ext.load_extensions() == [expected] -# ext.load_extensions + def test_gets_wrong_class(self, iter_entry_points_mock): -@pytest.fixture -def iter_entry_points_mock(request): - patcher = mock.patch('pkg_resources.iter_entry_points') - iter_entry_points = patcher.start() - iter_entry_points.return_value = [] - request.addfinalizer(patcher.stop) - return iter_entry_points + class WrongClass(object): + pass + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = WrongClass -def test_load_extensions_no_extenions(iter_entry_points_mock): - iter_entry_points_mock.return_value = [] - assert [] == ext.load_extensions() + iter_entry_points_mock.return_value = [mock_entry_point] + assert ext.load_extensions() == [] -def test_load_extensions(iter_entry_points_mock): - mock_entry_point = mock.Mock() - mock_entry_point.load.return_value = TestExtension + def test_gets_instance(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension() - iter_entry_points_mock.return_value = [mock_entry_point] + iter_entry_points_mock.return_value = [mock_entry_point] - expected = ext.ExtensionData( - any_testextension, mock_entry_point, IsA(config.ConfigSchema), - any_unicode, None) + assert ext.load_extensions() == [] - assert ext.load_extensions() == [expected] + def test_creating_instance_fails(self, iter_entry_points_mock): + mock_extension = mock.Mock(spec=ext.Extension) + mock_extension.side_effect = Exception + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = mock_extension -def test_load_extensions_gets_wrong_class(iter_entry_points_mock): + iter_entry_points_mock.return_value = [mock_entry_point] - class WrongClass(object): - pass + assert ext.load_extensions() == [] - mock_entry_point = mock.Mock() - mock_entry_point.load.return_value = WrongClass + def test_get_config_schema_fails(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension - iter_entry_points_mock.return_value = [mock_entry_point] + iter_entry_points_mock.return_value = [mock_entry_point] - assert [] == ext.load_extensions() + with mock.patch.object(TestExtension, 'get_config_schema') as get: + get.side_effect = Exception + assert ext.load_extensions() == [] + get.assert_called_once_with() -def test_load_extensions_gets_instance(iter_entry_points_mock): - mock_entry_point = mock.Mock() - mock_entry_point.load.return_value = TestExtension() + def test_get_default_config_fails(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension - iter_entry_points_mock.return_value = [mock_entry_point] + iter_entry_points_mock.return_value = [mock_entry_point] - assert [] == ext.load_extensions() + with mock.patch.object(TestExtension, 'get_default_config') as get: + get.side_effect = Exception + assert ext.load_extensions() == [] + get.assert_called_once_with() -def test_load_extensions_creating_instance_fails(iter_entry_points_mock): - mock_extension = mock.Mock(spec=ext.Extension) - mock_extension.side_effect = Exception + def test_get_command_fails(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = TestExtension - mock_entry_point = mock.Mock() - mock_entry_point.load.return_value = mock_extension + iter_entry_points_mock.return_value = [mock_entry_point] - iter_entry_points_mock.return_value = [mock_entry_point] + with mock.patch.object(TestExtension, 'get_command') as get: + get.side_effect = Exception - assert [] == ext.load_extensions() + assert ext.load_extensions() == [] + get.assert_called_once_with() -def test_load_extensions_get_config_schema_fails(iter_entry_points_mock): - mock_entry_point = mock.Mock() - mock_entry_point.load.return_value = TestExtension +class ValidateExtensionDataTest(object): - iter_entry_points_mock.return_value = [mock_entry_point] + @pytest.fixture + def ext_data(self): + extension = TestExtension() - with mock.patch.object(TestExtension, 'get_config_schema') as get_schema: - get_schema.side_effect = Exception - assert [] == ext.load_extensions() - get_schema.assert_called_once_with() + entry_point = mock.Mock() + entry_point.name = extension.ext_name + schema = extension.get_config_schema() + defaults = extension.get_default_config() + command = extension.get_command() -def test_load_extensions_get_default_config_fails(iter_entry_points_mock): - mock_entry_point = mock.Mock() - mock_entry_point.load.return_value = TestExtension + return ext.ExtensionData( + extension, entry_point, schema, defaults, command) - iter_entry_points_mock.return_value = [mock_entry_point] - - with mock.patch.object(TestExtension, 'get_default_config') as get_default: - get_default.side_effect = Exception - assert [] == ext.load_extensions() - get_default.assert_called_once_with() - - -def test_load_extensions_get_command_fails(iter_entry_points_mock): - mock_entry_point = mock.Mock() - mock_entry_point.load.return_value = TestExtension - - iter_entry_points_mock.return_value = [mock_entry_point] - - with mock.patch.object(TestExtension, 'get_command') as get_command: - get_command.side_effect = Exception - assert [] == ext.load_extensions() - get_command.assert_called_once_with() - - -# ext.validate_extension_data - -@pytest.fixture -def ext_data(): - extension = TestExtension() - - entry_point = mock.Mock() - entry_point.name = extension.ext_name - - schema = extension.get_config_schema() - defaults = extension.get_default_config() - command = extension.get_command() - - return ext.ExtensionData(extension, entry_point, schema, defaults, command) - - -def test_validate_extension_name_mismatch(ext_data): - ext_data.entry_point.name = 'barfoo' - assert not ext.validate_extension_data(ext_data) - - -def test_validate_extension_distribution_not_found(ext_data): - error = pkg_resources.DistributionNotFound - ext_data.entry_point.require.side_effect = error - assert not ext.validate_extension_data(ext_data) - - -def test_validate_extension_version_conflict(ext_data): - ext_data.entry_point.require.side_effect = pkg_resources.VersionConflict - assert not ext.validate_extension_data(ext_data) - - -def test_validate_extension_exception(ext_data): - ext_data.entry_point.require.side_effect = Exception - - # We trust that entry points are well behaved, so exception will bubble. - with pytest.raises(Exception): + def test_name_mismatch(self, ext_data): + ext_data.entry_point.name = 'barfoo' assert not ext.validate_extension_data(ext_data) - -def test_validate_extension_instance_validate_env_ext_error(ext_data): - extension = ext_data.extension - with mock.patch.object(extension, 'validate_environment') as validate: - validate.side_effect = exceptions.ExtensionError('error') - + def test_distribution_not_found(self, ext_data): + error = pkg_resources.DistributionNotFound + ext_data.entry_point.require.side_effect = error assert not ext.validate_extension_data(ext_data) - validate.assert_called_once_with() - - -def test_validate_extension_instance_validate_env_exception(ext_data): - extension = ext_data.extension - with mock.patch.object(extension, 'validate_environment') as validate: - validate.side_effect = Exception + def test_version_conflict(self, ext_data): + error = pkg_resources.VersionConflict + ext_data.entry_point.require.side_effect = error assert not ext.validate_extension_data(ext_data) - validate.assert_called_once_with() + def test_entry_point_require_exception(self, ext_data): + ext_data.entry_point.require.side_effect = Exception -def test_validate_extension_with_missing_schema(ext_data): - ext_data = ext_data._replace(config_schema=None) - assert not ext.validate_extension_data(ext_data) + # Hope that entry points are well behaved, so exception will bubble. + with pytest.raises(Exception): + assert not ext.validate_extension_data(ext_data) + def test_extenions_validate_environment_error(self, ext_data): + extension = ext_data.extension + with mock.patch.object(extension, 'validate_environment') as validate: + validate.side_effect = exceptions.ExtensionError('error') -def test_validate_extension_with_schema_that_is_missing_enabled(ext_data): - del ext_data.config_schema['enabled'] - ext_data.config_schema['baz'] = config.String() - assert not ext.validate_extension_data(ext_data) + assert not ext.validate_extension_data(ext_data) + validate.assert_called_once_with() + def test_extenions_validate_environment_exception(self, ext_data): + extension = ext_data.extension + with mock.patch.object(extension, 'validate_environment') as validate: + validate.side_effect = Exception -def test_validate_extension_with_schema_with_wrong_types(ext_data): - ext_data.config_schema['enabled'] = 123 - assert not ext.validate_extension_data(ext_data) + assert not ext.validate_extension_data(ext_data) + validate.assert_called_once_with() + def test_missing_schema(self, ext_data): + ext_data = ext_data._replace(config_schema=None) + assert not ext.validate_extension_data(ext_data) -def test_validate_extension_with_schema_with_invalid_type(ext_data): - ext_data.config_schema['baz'] = 123 - assert not ext.validate_extension_data(ext_data) + def test_schema_that_is_missing_enabled(self, ext_data): + del ext_data.config_schema['enabled'] + ext_data.config_schema['baz'] = config.String() + assert not ext.validate_extension_data(ext_data) + def test_schema_with_wrong_types(self, ext_data): + ext_data.config_schema['enabled'] = 123 + assert not ext.validate_extension_data(ext_data) -def test_validate_extension_with_no_defaults(ext_data): - ext_data = ext_data._replace(config_defaults=None) - assert not ext.validate_extension_data(ext_data) + def test_schema_with_invalid_type(self, ext_data): + ext_data.config_schema['baz'] = 123 + assert not ext.validate_extension_data(ext_data) + + def test_no_default_config(self, ext_data): + ext_data = ext_data._replace(config_defaults=None) + assert not ext.validate_extension_data(ext_data) From ce3c16de6ecf8150d8d15e2576836eebecfc697c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 12 May 2015 22:02:19 +0200 Subject: [PATCH 210/318] startup: Fix mistake in command extension bootstrap cleanup --- mopidy/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 9e1e1c94..ea1cab6b 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -70,7 +70,7 @@ def main(): for data in extensions_data: if data.command: # TODO: check isinstance? - data.command.set(extension=data.command) + data.command.set(extension=data.extension) root_cmd.add_child(data.extension.ext_name, data.command) args = root_cmd.parse(mopidy_args) From 2d952570d0085013dd79f577b669f70f6f3d86bc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 12 May 2015 00:06:31 +0200 Subject: [PATCH 211/318] main: Catch extension.setup exceptions --- mopidy/__main__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ea1cab6b..245a03ce 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -140,7 +140,16 @@ def main(): return 1 for extension in extensions['enabled']: - extension.setup(registry) + try: + extension.setup(registry) + except Exception: + # TODO: would be nice a transactional registry. But sadly this + # is a bit tricky since our current API is giving out a mutable + # list. We might however be able to replace this with a + # collections.Sequence to provide a RO view. + logger.exception('Extension %s failed during setup, this might' + ' have left the registry in a bad state.', + extension.ext_name) # Anything that wants to exit after this point must use # mopidy.internal.process.exit_process as actors can have been started. From cb4b23f416fdf4187982132a09747707e19140a2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 12 May 2015 00:35:00 +0200 Subject: [PATCH 212/318] startup: Handle potential mixer startup issues. --- mopidy/commands.py | 26 +++++++++++++++++++------- mopidy/mixer.py | 4 ++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index 24acfb7d..137e4a3e 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -10,6 +10,8 @@ import glib import gobject +import pykka + from mopidy import config as config_lib, exceptions from mopidy.audio import Audio from mopidy.core import Core @@ -230,6 +232,7 @@ class Command(object): raise NotImplementedError +# TODO: move out of this utility class class RootCommand(Command): def __init__(self): @@ -276,6 +279,8 @@ class RootCommand(Command): mixer = None if mixer_class is not None: mixer = self.start_mixer(config, mixer_class) + if mixer: + self.configure_mixer(config, mixer) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) core = self.start_core(config, mixer, backends, audio) @@ -322,16 +327,23 @@ class RootCommand(Command): return selected_mixers[0] def start_mixer(self, config, mixer_class): + logger.info('Starting Mopidy mixer: %s', mixer_class.__name__) + mixer = None + try: - logger.info('Starting Mopidy mixer: %s', mixer_class.__name__) mixer = mixer_class.start(config=config).proxy() - self.configure_mixer(config, mixer) - return mixer + mixer.ping().get() except exceptions.MixerError as exc: - logger.error( - 'Mixer (%s) initialization error: %s', - mixer_class.__name__, exc.message) - raise + logger.error('Mixer (%s) initialization error: %s', + mixer_class.__name__, exc.message) + except pykka.ActorDeadError as exc: + mixer = None + logger.error('Mixer actor died: %s', exc) + except Exception: + logger.exception('Mixer (%s) initialization exception:', + mixer_class.__name__) + + return mixer def configure_mixer(self, config, mixer): volume = config['audio']['mixer_volume'] diff --git a/mopidy/mixer.py b/mopidy/mixer.py index b25688fb..eb43d810 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -110,6 +110,10 @@ class Mixer(object): logger.debug('Mixer event: mute_changed(mute=%s)', mute) MixerListener.send('mute_changed', mute=mute) + def ping(self): + """Called to check if the actor is still alive.""" + return True + class MixerListener(listener.Listener): From 399124bf464d11357ac5fa4c3413ee3b832e27cf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 12 May 2015 21:43:50 +0200 Subject: [PATCH 213/318] startup: Handle frontend and backend failures --- mopidy/backend.py | 4 +++ mopidy/commands.py | 64 ++++++++++++++++++++++++++-------------------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 72293f94..0cea0f85 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -58,6 +58,10 @@ class Backend(object): def has_playlists(self): return self.playlists is not None + def ping(self): + """Called to check if the actor is still alive.""" + return True + class LibraryProvider(object): diff --git a/mopidy/commands.py b/mopidy/commands.py index 137e4a3e..4890c722 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, print_function, unicode_literals import argparse import collections +import contextlib import logging import os import sys @@ -232,6 +233,23 @@ class Command(object): raise NotImplementedError +@contextlib.contextmanager +def _actor_error_handling(name): + try: + yield + except exceptions.BackendError as exc: + logger.error( + 'Backend (%s) initialization error: %s', name, exc.message) + except exceptions.FrontendError as exc: + logger.error( + 'Frontend (%s) initialization error: %s', name, exc.message) + except exceptions.MixerError as exc: + logger.error( + 'Mixer (%s) initialization error: %s', name, exc.message) + except Exception: + logger.exception('Got un-handled exception from %s', name) + + # TODO: move out of this utility class class RootCommand(Command): @@ -328,22 +346,14 @@ class RootCommand(Command): def start_mixer(self, config, mixer_class): logger.info('Starting Mopidy mixer: %s', mixer_class.__name__) - mixer = None - - try: + with _actor_error_handling(mixer_class.__name__): mixer = mixer_class.start(config=config).proxy() - mixer.ping().get() - except exceptions.MixerError as exc: - logger.error('Mixer (%s) initialization error: %s', - mixer_class.__name__, exc.message) - except pykka.ActorDeadError as exc: - mixer = None - logger.error('Mixer actor died: %s', exc) - except Exception: - logger.exception('Mixer (%s) initialization exception:', - mixer_class.__name__) - - return mixer + try: + mixer.ping().get() + return mixer + except pykka.ActorDeadError as exc: + logger.error('Actor died: %s', exc) + return None def configure_mixer(self, config, mixer): volume = config['audio']['mixer_volume'] @@ -364,16 +374,19 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: - try: + with _actor_error_handling(backend_class.__name__): with timer.time_logger(backend_class.__name__): backend = backend_class.start( config=config, audio=audio).proxy() - backends.append(backend) - except exceptions.BackendError as exc: - logger.error( - 'Backend (%s) initialization error: %s', - backend_class.__name__, exc.message) - raise + backends.append(backend) + + # Block until all on_starts have finished, letting them run in parallel + for backend in backends[:]: + try: + backend.ping().get() + except pykka.ActorDeadError as exc: + backends.remove(backend) + logger.error('Actor died: %s', exc) return backends @@ -388,14 +401,9 @@ class RootCommand(Command): ', '.join(f.__name__ for f in frontend_classes) or 'none') for frontend_class in frontend_classes: - try: + with _actor_error_handling(frontend_class.__name__): with timer.time_logger(frontend_class.__name__): frontend_class.start(config=config, core=core) - except exceptions.FrontendError as exc: - logger.error( - 'Frontend (%s) initialization error: %s', - frontend_class.__name__, exc.message) - raise def stop_frontends(self, frontend_classes): logger.info('Stopping Mopidy frontends') From 8c7b3c69fb30294bb45f69694e374bb648262d57 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 12 May 2015 22:39:30 +0200 Subject: [PATCH 214/318] core: Assume backend.has_* calls could fail --- mopidy/core/actor.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index b6318492..f20b0ba2 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import collections import itertools +import logging import pykka @@ -18,6 +19,9 @@ from mopidy.internal import versioning from mopidy.internal.deprecation import deprecated_property +logger = logging.getLogger(__name__) + + class Core( pykka.ThreadingActor, audio.AudioListener, backend.BackendListener, mixer.MixerListener): @@ -145,10 +149,15 @@ class Backends(list): return b.actor_ref.actor_class.__name__ for b in backends: - has_library = b.has_library().get() - has_library_browse = b.has_library_browse().get() - has_playback = b.has_playback().get() - has_playlists = b.has_playlists().get() + try: + has_library = b.has_library().get() + has_library_browse = b.has_library_browse().get() + has_playback = b.has_playback().get() + has_playlists = b.has_playlists().get() + except Exception: + self.remove(b) + logger.exception('Fetching backend info for %s failed', + b.actor_ref.actor_class.__name__) for scheme in b.uri_schemes.get(): assert scheme not in backends_by_scheme, ( From 7073e8dd77cf407bedce407888265fb31e7fbbba Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Thu, 14 May 2015 23:25:25 +0100 Subject: [PATCH 215/318] Docs: Notes for installation on XBian --- docs/installation/raspberrypi.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 3c70340c..c8793496 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -173,3 +173,20 @@ More info about this issue can be found in `this post Please note that if you're running Xbian or another XBMC distribution these instructions might vary for your system. + + +Appendix C: Installation on XBian +================================= + +Similar to the Raspbmc issue outlined in Appendix B, it's not possible to +install Mopidy on XBian without first resolving a dependency problem between +``gstreamer0.10-plugins-good`` and ``libtag1c2a``. More information can be +found in `this post +`_. + +Run the following commands to remedy this and then install Mopidy as normal:: + + cd /tmp + wget http://apt.xbian.org/pool/stable/rpi-wheezy/l/libtag1c2a/libtag1c2a_1.7.2-1_armhf.deb + sudo dpkg -i libtag1c2a_1.7.2-1_armhf.deb + rm libtag1c2a_1.7.2-1_armhf.deb From f8c99b5daba2a3796952ff2bccb6ee7367324112 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:35:11 +0200 Subject: [PATCH 216/318] docs: Remove PMix from MPD client list It is no longer available on Google Play, and we didn't recommend it 2.5y ago when it was. (cherry picked from commit d37b76f6c94a781f5b48a0e6719e08a6b69a6513) --- docs/clients/mpd.rst | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 91d0f8db..9ff888bc 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -234,21 +234,6 @@ You can get `Droid MPD Client from Google Play In conclusion, not a client we can recommend. -PMix ----- - -Test date: - 2012-11-06 -Tested version: - 0.4.0 (released 2010-03-06) - -You can get `PMix from Google Play -`_. - -PMix haven't been updated for 2.5 years, and has less working features than -it's fork MPDroid. Ignore PMix and use MPDroid instead. - - MPD Remote ---------- From 2ed019b69fff20994c3c26f42db75a2f7229f82e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:36:24 +0200 Subject: [PATCH 217/318] docs: Remove MPD Remote from MPD client list It looked terrible 2.5y ago and I didn't care to test it. It has seen no updates since. (cherry picked from commit c95b3016965c6c1f4f1b49ad697435938caccb5b) --- docs/clients/mpd.rst | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 9ff888bc..760d2f6e 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -234,21 +234,6 @@ You can get `Droid MPD Client from Google Play In conclusion, not a client we can recommend. -MPD Remote ----------- - -Test date: - 2012-11-06 -Tested version: - 1.0 (released 2012-05-01) - -You can get `MPD Remote from Google Play -`_. - -This app looks terrible in the screen shots, got just 100+ downloads, and got a -terrible rating. I honestly didn't take the time to test it. - - .. _ios_mpd_clients: iOS clients From 2f05c1cff34db41d7cf8b529624e3d2b5d39864e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:37:51 +0200 Subject: [PATCH 218/318] docs: Remove bitMPC from MPD client list It looked bad and only worked on Android 2.x when tested 2.5y ago. It has seen no updates since 2010. (cherry picked from commit 0b3b17e3a66e6ae6a1771a5ee4a92d594ee5f1b8) --- docs/clients/mpd.rst | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 760d2f6e..4d7a8881 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -170,37 +170,6 @@ You can get `MPDroid from Google Play MPDroid is a good MPD client, and really the only one we can recommend. -BitMPC ------- - -Test date: - 2012-11-06 -Tested version: - 1.0.0 (released 2010-04-12) - -You can get `BitMPC from Google Play -`_. - -- The user interface lacks some finishing touches. E.g. you can't enter a - hostname for the server. Only IPv4 addresses are allowed. - -- When we last tested the same version of BitMPC using Android 2.1: - - - All features exercised in the test procedure worked. - - - BitMPC lacked support for single mode and consume mode. - - - BitMPC crashed if Mopidy was killed or crashed. - -- When we tried to test using Android 4.1.1, BitMPC started and connected to - Mopidy without problems, but the app crashed as soon as we fired off our - search, and continued to crash on startup after that. - -In conclusion, BitMPC is usable if you got an older Android phone and don't -care about looks. For newer Android versions, BitMPC will probably not work as -it hasn't been maintained for 2.5 years. - - Droid MPD Client ---------------- From 85d85739ce45be88b492496533af7f90b2f5470c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:39:44 +0200 Subject: [PATCH 219/318] docs: Remove Droid MPD Client from MPD client list We couldn't recommend it 2.5y ago, and it has seen no updates since. (cherry picked from commit 0273b14c70f7ca63cfaf020a55225172134aad0b) --- docs/clients/mpd.rst | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 4d7a8881..36af7b84 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -170,39 +170,6 @@ You can get `MPDroid from Google Play MPDroid is a good MPD client, and really the only one we can recommend. -Droid MPD Client ----------------- - -Test date: - 2012-11-06 -Tested version: - 1.4.0 (released 2011-12-20) - -You can get `Droid MPD Client from Google Play -`_. - -- No intutive way to ask the app to connect to the server after adding the - server hostname to the settings. - -- To find the search functionality, you have to select the menu, - then "Playlist manager", then the search tab. I do not understand why search - is hidden inside "Playlist manager". - -- The tabs "Artists" and "Albums" did not contain anything, and did not cause - any requests. - -- The tab "Folders" showed a spinner and said "Updating data..." but did not - send any requests. - -- Searching for "foo" did nothing. No request was sent to the server. - -- Droid MPD client does not support single mode or consume mode. - -- Not able to complete the test procedure, due to the above problems. - -In conclusion, not a client we can recommend. - - .. _ios_mpd_clients: iOS clients From 8027befe9c995b5224f9bb8184bad9cd473551d1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:43:25 +0200 Subject: [PATCH 220/318] docs: Include 'MPD' in the subsection headers (cherry picked from commit b03d3a8a1c61f3f5074501da1910d0f6337d8441) --- docs/clients/mpd.rst | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 36af7b84..ed1df581 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -41,9 +41,8 @@ completeness of clients: #. Kill Mopidy and confirm that the app handles it without crashing - -Console clients -=============== +MPD console clients +=================== ncmpcpp ------- @@ -83,8 +82,8 @@ A command line client. Version 0.16 and upwards seems to work nicely with Mopidy. -Graphical clients -================= +MPD graphical clients +===================== GMPC ---- @@ -132,8 +131,8 @@ client for OS X. It is unmaintained, but generally works well with Mopidy. .. _android_mpd_clients: -Android clients -=============== +MPD Android clients +=================== We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test @@ -172,8 +171,8 @@ MPDroid is a good MPD client, and really the only one we can recommend. .. _ios_mpd_clients: -iOS clients -=========== +MPD iOS clients +=============== MPoD ---- @@ -236,8 +235,8 @@ purchased from `MPaD at iTunes Store .. _mpd-web-clients: -Web clients -=========== +MPD web clients +=============== The following web clients use the MPD protocol to communicate with Mopidy. For other web clients, see :ref:`http-clients`. From 14b9c12d093912cf0cc4c6441d8f8e929d970138 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 11 May 2015 21:48:19 +0200 Subject: [PATCH 221/318] docs: Remove MPD client test procedure and outdated results (cherry picked from commit 4ede30436a32e65461053cf686c1f3606e5389a2) --- docs/clients/mpd.rst | 86 -------------------------------------------- 1 file changed, 86 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index ed1df581..fe7ef21d 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -12,35 +12,6 @@ http://mpd.wikia.com/wiki/Clients. :local: -Test procedure -============== - -In some cases, we've used the following test procedure to compare the feature -completeness of clients: - -#. Connect to Mopidy -#. Search for "foo", with search type "any" if it can be selected -#. Add "The Pretender" from the search results to the current playlist -#. Start playback -#. Pause and resume playback -#. Adjust volume -#. Find a playlist and append it to the current playlist -#. Skip to next track -#. Skip to previous track -#. Select the last track from the current playlist -#. Turn on repeat mode -#. Seek to 10 seconds or so before the end of the track -#. Wait for the end of the track and confirm that playback continues at the - start of the playlist -#. Turn off repeat mode -#. Turn on random mode -#. Skip to next track and confirm that it random mode works -#. Turn off random mode -#. Stop playback -#. Check if the app got support for single mode and consume mode -#. Kill Mopidy and confirm that the app handles it without crashing - - MPD console clients =================== @@ -134,19 +105,9 @@ client for OS X. It is unmaintained, but generally works well with Mopidy. MPD Android clients =================== -We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 -on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test -procedure. - - MPDroid ------- -Test date: - 2012-11-06 -Tested version: - 1.03.1 (released 2012-10-16) - .. image:: mpd-client-mpdroid.jpg :width: 288 :height: 512 @@ -154,18 +115,6 @@ Tested version: You can get `MPDroid from Google Play `_. -- MPDroid started out as a fork of PMix, and is now much better. - -- MPDroid's user interface looks nice. - -- Everything in the test procedure works. - -- In contrast to all other Android clients, MPDroid does support single mode or - consume mode. - -- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to - try to reconnect. - MPDroid is a good MPD client, and really the only one we can recommend. @@ -177,11 +126,6 @@ MPD iOS clients MPoD ---- -Test date: - 2012-11-06 -Tested version: - 1.7.1 - .. image:: mpd-client-mpod.jpg :width: 320 :height: 480 @@ -190,26 +134,10 @@ The `MPoD `_ iPhone/iPod Touch app can be installed from `MPoD at iTunes Store `_. -- The user interface looks nice. - -- All features exercised in the test procedure worked with MPaD, except seek, - which I didn't figure out to do. - -- Search only works in the "Browse" tab, and not under in the "Artist", - "Album", or "Song" tabs. For the tabs where search doesn't work, no queries - are sent to Mopidy when searching. - -- Single mode and consume mode is supported. - MPaD ---- -Test date: - 2012-11-06 -Tested version: - 1.7.1 - .. image:: mpd-client-mpad.jpg :width: 480 :height: 360 @@ -218,20 +146,6 @@ The `MPaD `_ iPad app can be purchased from `MPaD at iTunes Store `_ -- The user interface looks nice, though I would like to be able to view the - current playlist in the large part of the split view. - -- All features exercised in the test procedure worked with MPaD. - -- Search only works in the "Browse" tab, and not under in the "Artist", - "Album", or "Song" tabs. For the tabs where search doesn't work, no queries - are sent to Mopidy when searching. - -- Single mode and consume mode is supported. - -- The server menu can be very slow top open, and there is no visible feedback - when waiting for the connection to a server to succeed. - .. _mpd-web-clients: From 7023bcded6b3b41f704071ba5d6eaae3d337f449 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 19 May 2015 22:04:37 +0200 Subject: [PATCH 222/318] docs: Update changelog for v1.0.5 --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d87b16a9..4aad8690 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,7 +5,7 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.5 (UNRELEASED) +v1.0.5 (2015-05-19) =================== Bug fix release. From b0a776114d245a4f6117c6fdc83816841d46cdab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 19 May 2015 22:05:20 +0200 Subject: [PATCH 223/318] Bump version to 1.0.5 --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 0bc5410e..802a44d4 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.4' +__version__ = '1.0.5' diff --git a/tests/test_version.py b/tests/test_version.py index 37d0b459..ed413cc1 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -59,5 +59,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('1.0.0', '1.0.1') self.assertVersionLess('1.0.1', '1.0.2') self.assertVersionLess('1.0.2', '1.0.3') - self.assertVersionLess('1.0.3', __version__) - self.assertVersionLess(__version__, '1.0.5') + self.assertVersionLess('1.0.3', '1.0.4') + self.assertVersionLess('1.0.4', __version__) + self.assertVersionLess(__version__, '1.0.6') From 31509ea56854523dd9dfa04be51f5ef864e04d53 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 19 May 2015 22:37:35 +0200 Subject: [PATCH 224/318] core/mpd/local: Add title to get_distinct field types --- docs/changelog.rst | 8 ++++++++ mopidy/backend.py | 3 +++ mopidy/core/library.py | 4 ++-- mopidy/local/json.py | 5 ++++- mopidy/mpd/protocol/music_db.py | 2 ++ 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4aad8690..4dce587c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,14 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.6 (unreleased) +=================== + +Bug fix release. + +- Core/MPD/Local: Add support for ``title`` in + :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`1181`) + v1.0.5 (2015-05-19) =================== diff --git a/mopidy/backend.py b/mopidy/backend.py index 2bbc1eea..4503a9ee 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -97,6 +97,9 @@ class LibraryProvider(object): *MAY be implemented by subclass.* Default implementation will simply return an empty set. + + Note that backends should always return an empty set for unexpected + field types. """ return set() diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 89a2037a..ed0b292e 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -85,8 +85,8 @@ class LibraryController(object): protocol supports in a more sane fashion. Other frontends are not recommended to use this method. - :param string field: One of ``artist``, ``albumartist``, ``album``, - ``composer``, ``performer``, ``date``or ``genre``. + :param string field: One of ``title``, ``artist``, ``albumartist``, + ``album``, ``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. diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 22fcfa5b..a7500505 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -141,7 +141,10 @@ class JsonLibrary(local.Library): return [] def get_distinct(self, field, query=None): - if field == 'artist': + if field == 'title': + def distinct(track): + return {track.name} + elif field == 'artist': def distinct(track): return {a.name for a in track.artists} elif field == 'albumartist': diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index a942abf5..9f1aaf4c 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -22,6 +22,7 @@ _SEARCH_MAPPING = { 'track': 'track_no'} _LIST_MAPPING = { + 'title': 'title', 'album': 'album', 'albumartist': 'albumartist', 'artist': 'artist', @@ -31,6 +32,7 @@ _LIST_MAPPING = { 'performer': 'performer'} _LIST_NAME_MAPPING = { + 'title': 'Title', 'album': 'Album', 'albumartist': 'AlbumArtist', 'artist': 'Artist', From 2b3e976bc9598d10ce30176ccfa76b25307c67e3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 20 May 2015 23:14:46 +0200 Subject: [PATCH 225/318] core: Update title distinct name to track --- mopidy/core/library.py | 2 +- mopidy/local/json.py | 2 +- mopidy/mpd/protocol/music_db.py | 2 +- tests/mpd/protocol/test_music_db.py | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ed0b292e..3cfb390c 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -85,7 +85,7 @@ class LibraryController(object): protocol supports in a more sane fashion. Other frontends are not recommended to use this method. - :param string field: One of ``title``, ``artist``, ``albumartist``, + :param string field: One of ``track``, ``artist``, ``albumartist``, ``album``, ``composer``, ``performer``, ``date``or ``genre``. :param dict query: Query to use for limiting results, see :meth:`search` for details about the query format. diff --git a/mopidy/local/json.py b/mopidy/local/json.py index a7500505..0945f86f 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -141,7 +141,7 @@ class JsonLibrary(local.Library): return [] def get_distinct(self, field, query=None): - if field == 'title': + if field == 'track': def distinct(track): return {track.name} elif field == 'artist': diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 9f1aaf4c..de800f4b 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -22,7 +22,7 @@ _SEARCH_MAPPING = { 'track': 'track_no'} _LIST_MAPPING = { - 'title': 'title', + 'title': 'track', 'album': 'album', 'albumartist': 'albumartist', 'artist': 'artist', diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index b9fbcdf6..32fb3e25 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -624,6 +624,11 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.send_request('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') + # Track title + def test_list_title(self): + self.send_request('list "title"') + self.assertInResponse('OK') + # Artist def test_list_artist_with_quotes(self): From 1d636ce59e8fbe0eca47d75b8bbec666eccb22cd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 20 May 2015 23:26:55 +0200 Subject: [PATCH 226/318] core: Make sure track gets changed while paused --- docs/changelog.rst | 9 +++++++++ mopidy/core/playback.py | 6 ++++++ tests/core/test_playback.py | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4aad8690..7dd3fb1c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,15 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.6 (unreleased) +=================== + +Bug fix release. + +- Core: Make sure track changes make it to audio while paused. + (Fixes: :issuse:`1177`) + + v1.0.5 (2015-05-19) =================== diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 61bbc60c..d13ebdb3 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -198,6 +198,12 @@ class PlaybackController(object): if old_state == PlaybackState.PLAYING: self._play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: + # NOTE: this is just a quick hack to fix #1177 as this code has + # already been killed in the gapless branch. + backend = self._get_backend() + if backend: + backend.playback.prepare_change() + backend.playback.change_track(tl_track.track).get() self.pause() # TODO: this is not really end of track, this is on_need_next_track diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 7c4db0d6..7f395c47 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -668,3 +668,27 @@ class CorePlaybackWithOldBackendTest(unittest.TestCase): c = core.Core(mixer=None, backends=[b]) c.tracklist.add([Track(uri='dummy1:a', length=40000)]) c.playback.play() # No TypeError == test passed. + b.playback.play.assert_called_once_with() + + +class Bug1177RegressionTest(unittest.TestCase): + def test(self): + b = mock.Mock() + b.uri_schemes.get.return_value = ['dummy'] + b.playback = mock.Mock(spec=backend.PlaybackProvider) + b.playback.change_track.return_value.get.return_value = True + b.playback.play.return_value.get.return_value = True + + track1 = Track(uri='dummy:a', length=40000) + track2 = Track(uri='dummy:b', length=40000) + + c = core.Core(mixer=None, backends=[b]) + c.tracklist.add([track1, track2]) + + c.playback.play() + b.playback.change_track.assert_called_once_with(track1) + b.playback.change_track.reset_mock() + + c.playback.pause() + c.playback.next() + b.playback.change_track.assert_called_once_with(track2) From dd6e8c0f77f3d990120e73dd933779228f14e092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dra=C5=BEen=20Lu=C4=8Danin?= Date: Tue, 26 May 2015 18:46:08 +0200 Subject: [PATCH 227/318] docs: describe the library update steps --- docs/running.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/running.rst b/docs/running.rst index 2c7ced21..e329ccaa 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -13,6 +13,20 @@ When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to accept connections by any MPD client. Check out our non-exhaustive :doc:`/clients/mpd` list to find recommended clients. +Updating the library +==================== + +To update the library, e.g. after audio files have changed, run:: + + mopidy local scan + +Afterwards, to refresh the library (which is for now only available +through the API) it is necessary to run:: + + curl -d '{"jsonrpc": "2.0", "id": 1, "method": "core.library.refresh"}' http://localhost:6680/mopidy/rpc + +This makes the changes in the library visible to the clients. + Stopping Mopidy =============== From feb9963a8e1fe281468e73b29137835fc7c9210c Mon Sep 17 00:00:00 2001 From: Naglis Jonaitis Date: Thu, 28 May 2015 00:17:20 +0300 Subject: [PATCH 228/318] mpd: Ignore tracks without length in the "count" command --- docs/changelog.rst | 6 ++++++ mopidy/mpd/protocol/music_db.py | 2 +- tests/mpd/protocol/test_music_db.py | 10 ++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ac0531a7..941dcd7c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -44,6 +44,12 @@ Models reuse instances. For the test data set this was developed against, a library of ~14000 tracks, went from needing ~75MB to ~17MB. (Fixes: :issue:`348`) +MPD frontend +------------ + +- The MPD command ``count`` now ignores tracks with no length, which would + previously cause a :exc:`TypeError`. (PR: :issue:`1192`) + Utils ----- diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 510d3ac1..1b3a3ee7 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -105,7 +105,7 @@ def count(context, *args): result_tracks = _get_tracks(results) return [ ('songs', len(result_tracks)), - ('playtime', sum(track.length for track in result_tracks) / 1000), + ('playtime', sum(t.length for t in result_tracks if t.length) / 1000), ] diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 73c3b300..acdfbe13 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -79,6 +79,16 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('playtime: 650') self.assertInResponse('OK') + def test_count_with_track_length_none(self): + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[ + Track(uri='dummy:b', date="2001", length=None), + ]) + self.send_request('count "date" "2001"') + self.assertInResponse('songs: 1') + self.assertInResponse('playtime: 0') + self.assertInResponse('OK') + def test_findadd(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) From be272c0abbb97325b44917f1a14d7873e78670c8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 3 Jun 2015 23:13:17 +0200 Subject: [PATCH 229/318] docs: Remove refs to ci.mopidy.com --- docs/devenv.rst | 7 ------- docs/sponsors.rst | 6 ------ 2 files changed, 13 deletions(-) diff --git a/docs/devenv.rst b/docs/devenv.rst index c67426f7..c00e6050 100644 --- a/docs/devenv.rst +++ b/docs/devenv.rst @@ -325,13 +325,6 @@ For each successful build, Travis submits code coverage data to `coveralls.io `_. If you're out of work, coveralls might help you find areas in the code which could need better test coverage. -In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all -tests on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push -to the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code -isn't tested by Jenkins before it is merged into the ``develop`` branch, which -is a bit late, but good enough to get broad testing before new code is -released. - .. _code-linting: diff --git a/docs/sponsors.rst b/docs/sponsors.rst index 67aef554..dc94aa6f 100644 --- a/docs/sponsors.rst +++ b/docs/sponsors.rst @@ -20,12 +20,6 @@ for free. We use their services for the following sites: - Mailgun for sending emails from the Discourse forum. -- Hosting of the Jenkins CI server at https://ci.mopidy.com. - -- Hosting of a Linux worker for https://ci.mopidy.com. - -- Hosting of a Windows worker for https://ci.mopidy.com. - - CDN hosting at http://dl.mopidy.com, which is used to distribute Pi Musicbox images. From c273718e5c7ad734141261f21367fb2d8a23ceae Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Thu, 4 Jun 2015 13:17:18 +0100 Subject: [PATCH 230/318] Docs: URI field docstring typo --- mopidy/models/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index 01a03a75..1f3935b4 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -98,7 +98,7 @@ class Identifier(String): class URI(Identifier): """ - :class`Field` for storing URIs + :class:`Field` for storing URIs Values will be interned, currently not validated. From fd93ca7679548f1a394ff341c22e449ebe603e60 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Jun 2015 22:25:05 +0200 Subject: [PATCH 231/318] docs: Update changelog for v1.0.6 --- docs/changelog.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7761d8ea..d3e3d21a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,16 +5,18 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.6 (unreleased) +v1.0.6 (2015-06-25) =================== Bug fix release. - Core/MPD/Local: Add support for ``title`` in - :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`1181`) + :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`1181`, + PR: :issue:`1183`) - Core: Make sure track changes make it to audio while paused. - (Fixes: :issuse:`1177`) + (Fixes: :issue:`1177`, PR: :issue:`1185`) + v1.0.5 (2015-05-19) =================== From f60ffdf33639dcadf2a88c674750d80c31009edc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Jun 2015 22:26:12 +0200 Subject: [PATCH 232/318] Bump version to 1.0.6 --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 802a44d4..5f2384a8 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.5' +__version__ = '1.0.6' diff --git a/tests/test_version.py b/tests/test_version.py index ed413cc1..82e30834 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -60,5 +60,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('1.0.1', '1.0.2') self.assertVersionLess('1.0.2', '1.0.3') self.assertVersionLess('1.0.3', '1.0.4') - self.assertVersionLess('1.0.4', __version__) - self.assertVersionLess(__version__, '1.0.6') + self.assertVersionLess('1.0.4', '1.0.5') + self.assertVersionLess('1.0.5', __version__) + self.assertVersionLess(__version__, '1.0.7') From 5cc019afa2424d0152b561a01e12c04e1ab08f4d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Jun 2015 23:15:59 +0200 Subject: [PATCH 233/318] validation: Add 'track' as accepted distinct field --- mopidy/internal/validation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/internal/validation.py b/mopidy/internal/validation.py index 7eedba20..52acc64f 100644 --- a/mopidy/internal/validation.py +++ b/mopidy/internal/validation.py @@ -17,7 +17,8 @@ TRACKLIST_FIELDS = { # TODO: add bitrate, length, disc_no, track_no, modified? 'uri', 'name', 'genre', 'date', 'comment', 'musicbrainz_id'} DISTINCT_FIELDS = { - 'artist', 'albumartist', 'album', 'composer', 'performer', 'date', 'genre'} + 'track', 'artist', 'albumartist', 'album', 'composer', 'performer', 'date', + 'genre'} # TODO: _check_iterable(check, msg, **kwargs) + [check(a) for a in arg]? From 0d032a25fa8f8db60fb8c2de2cf00d145e9749a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Jun 2015 23:16:45 +0200 Subject: [PATCH 234/318] mpd: Fix 'title' to 'track' and back conversion in list cmd --- mopidy/mpd/protocol/music_db.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 16375eb7..f9d77d5b 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -33,7 +33,7 @@ _LIST_MAPPING = { 'performer': 'performer'} _LIST_NAME_MAPPING = { - 'title': 'Title', + 'track': 'Title', 'album': 'Album', 'albumartist': 'AlbumArtist', 'artist': 'Artist', @@ -267,9 +267,10 @@ def list_(context, *args): params = list(args) if not params: raise exceptions.MpdArgError('incorrect arguments') - field = params.pop(0).lower() - if field not in _LIST_MAPPING: + field = params.pop(0).lower() + field = _LIST_MAPPING.get(field) + if field is None: raise exceptions.MpdArgError('incorrect arguments') query = None From c4e930a17bc34f2b8c5e7e847b142a2395b497cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Jun 2015 23:16:45 +0200 Subject: [PATCH 235/318] mpd: Fix 'title' to 'track' and back conversion in list cmd (cherry picked from commit 0d032a25fa8f8db60fb8c2de2cf00d145e9749a5) --- mopidy/mpd/protocol/music_db.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index de800f4b..1e80f2a0 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -32,7 +32,7 @@ _LIST_MAPPING = { 'performer': 'performer'} _LIST_NAME_MAPPING = { - 'title': 'Title', + 'track': 'Title', 'album': 'Album', 'albumartist': 'AlbumArtist', 'artist': 'Artist', @@ -260,9 +260,10 @@ def list_(context, *args): params = list(args) if not params: raise exceptions.MpdArgError('incorrect arguments') - field = params.pop(0).lower() - if field not in _LIST_MAPPING: + field = params.pop(0).lower() + field = _LIST_MAPPING.get(field) + if field is None: raise exceptions.MpdArgError('incorrect arguments') if len(params) == 1: From 1345f23b20f990530b31e2da3e19566645d0d057 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Jun 2015 23:23:48 +0200 Subject: [PATCH 236/318] docs: Update changelog --- docs/changelog.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d3e3d21a..e5445a2c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,15 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.7 (unreleased) +=================== + +Bug fix release. + +- Fix error in the MPD command ``list title ...``. The error was introduced in + v1.0.6. + + v1.0.6 (2015-06-25) =================== From 97a5ae737f678a96648592c6f912409341ce02a7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Jun 2015 00:34:24 +0200 Subject: [PATCH 237/318] docs: Update changelog for v1.0.7 --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e5445a2c..818619e4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,7 +5,7 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.7 (unreleased) +v1.0.7 (2015-06-26) =================== Bug fix release. From bc2f56d0bf40d2126536ed1847c5b97f793cab8a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Jun 2015 00:34:59 +0200 Subject: [PATCH 238/318] Bump version to 1.0.7 --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 5f2384a8..7ab3b9e6 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.6' +__version__ = '1.0.7' diff --git a/tests/test_version.py b/tests/test_version.py index 82e30834..f8afd3db 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -61,5 +61,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('1.0.2', '1.0.3') self.assertVersionLess('1.0.3', '1.0.4') self.assertVersionLess('1.0.4', '1.0.5') - self.assertVersionLess('1.0.5', __version__) - self.assertVersionLess(__version__, '1.0.7') + self.assertVersionLess('1.0.5', '1.0.6') + self.assertVersionLess('1.0.6', __version__) + self.assertVersionLess(__version__, '1.0.8') From 03d65432542cb0cedeb9c17ee17ef6bd88ba155f Mon Sep 17 00:00:00 2001 From: kyleheyne Date: Mon, 29 Jun 2015 14:22:08 -0400 Subject: [PATCH 239/318] Update osx.rst Changed brew upgrade to brew upgrade --all. --- docs/installation/osx.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst index 71beece3..e9ce16e3 100644 --- a/docs/installation/osx.rst +++ b/docs/installation/osx.rst @@ -18,7 +18,7 @@ If you are running OS X, you can install everything needed with Homebrew. date before you continue:: brew update - brew upgrade + brew upgrade --all Notice that this will upgrade all software on your system that have been installed with Homebrew. From bd70eac12492a880284372acb0ad0edbcc7635c8 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Tue, 30 Jun 2015 18:26:28 +0200 Subject: [PATCH 240/318] file-browser: initial commit --- mopidy/files/__init__.py | 33 +++++++++ mopidy/files/backend.py | 22 ++++++ mopidy/files/ext.conf | 8 ++ mopidy/files/library.py | 155 +++++++++++++++++++++++++++++++++++++++ mopidy/stream/ext.conf | 1 - setup.py | 1 + 6 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 mopidy/files/__init__.py create mode 100644 mopidy/files/backend.py create mode 100644 mopidy/files/ext.conf create mode 100644 mopidy/files/library.py diff --git a/mopidy/files/__init__.py b/mopidy/files/__init__.py new file mode 100644 index 00000000..cd5e41c1 --- /dev/null +++ b/mopidy/files/__init__.py @@ -0,0 +1,33 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os + +import mopidy +from mopidy import config, ext + +logger = logging.getLogger(__name__) + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-Files' + ext_name = 'files' + version = mopidy.__version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + def get_config_schema(self): + schema = super(Extension, self).get_config_schema() + schema['media_dir'] = config.List(optional=True) + schema['show_hidden'] = config.Boolean(optional=True) + schema['follow_symlinks'] = config.Boolean(optional=True) + schema['metadata_timeout'] = config.Integer( + minimum=1000, maximum=1000 * 60 * 60, optional=True) + return schema + + def setup(self, registry): + from .backend import FilesBackend + registry.add('backend', FilesBackend) diff --git a/mopidy/files/backend.py b/mopidy/files/backend.py new file mode 100644 index 00000000..2394881c --- /dev/null +++ b/mopidy/files/backend.py @@ -0,0 +1,22 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +import pykka + +from mopidy import backend +from mopidy.files import library + + +logger = logging.getLogger(__name__) + + +class FilesBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['file'] + + def __init__(self, config, audio): + super(FilesBackend, self).__init__() + self.library = library.FilesLibraryProvider(backend=self, + config=config) + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + self.playlists = None diff --git a/mopidy/files/ext.conf b/mopidy/files/ext.conf new file mode 100644 index 00000000..8068b528 --- /dev/null +++ b/mopidy/files/ext.conf @@ -0,0 +1,8 @@ +[files] +enabled = true +media_dir = + ~/:Home + /data/music/music_data:Music +show_hidden = false +follow_symlinks = true +metadata_timeout = 1000 \ No newline at end of file diff --git a/mopidy/files/library.py b/mopidy/files/library.py new file mode 100644 index 00000000..cad27638 --- /dev/null +++ b/mopidy/files/library.py @@ -0,0 +1,155 @@ +from __future__ import unicode_literals + +import logging +import operator +import os +import stat +import sys +import urllib2 + + +from mopidy import backend, exceptions, models +from mopidy.audio import scan, utils +from mopidy.internal import path + +logger = logging.getLogger(__name__) + + +class FilesLibraryProvider(backend.LibraryProvider): + """Library for browsing local files.""" + + @property + def root_directory(self): + if not self._media_dirs: + return None + elif len(self._media_dirs) == 1: + localpath = self._media_dirs[0]['path'] + uri = path.path_to_uri(localpath) + else: + uri = u'file:root' + return models.Ref.directory(name='Files', uri=uri) + + def __init__(self, backend, config): + super(FilesLibraryProvider, self).__init__(backend) + self._media_dirs = [] + # import pdb; pdb.set_trace() + for entry in config['files']['media_dir']: + media_dir = {} + media_dict = entry.split(':') + local_path = path.expand_path( + media_dict[0].encode(sys.getfilesystemencoding())) + st = os.stat(local_path) + if not stat.S_ISDIR(st.st_mode): + logger.warn(u'%s is not a directory' % local_path) + continue + media_dir['path'] = local_path + if len(media_dict) == 2: + media_dir['name'] = media_dict[1] + else: + media_dir['name'] = media_dict[0].replace(os.sep, '+') + self._media_dirs.append(media_dir) + logger.debug(self._media_dirs) + self._follow_symlinks = config['files']['follow_symlinks'] + self._show_hidden = config['files']['show_hidden'] + self._scanner = scan.Scanner( + timeout=config['files']['metadata_timeout']) + + def browse(self, uri, encoding=sys.getfilesystemencoding()): + logger.debug(u'browse called with uri %s' % uri) + # import pdb; pdb.set_trace() + result = [] + localpath = path.uri_to_path(uri) + if localpath == 'root': + result = self._show_media_dirs() + else: + if not self._is_in_basedir(localpath): + logger.warn(u'Not in basedir: %s' % localpath) + return [] + for name in os.listdir(localpath): + child = os.path.join(localpath, name) + uri = path.path_to_uri(child) + name = name.decode('ascii', 'ignore') + if self._follow_symlinks: + st = os.stat(child) + else: + st = os.lstat(child) + if not self._show_hidden and name.startswith(b'.'): + continue + elif stat.S_ISDIR(st.st_mode): + result.append(models.Ref.directory(name=name, uri=uri)) + elif stat.S_ISREG(st.st_mode) and self._check_audiofile(uri): + # if self._is_playlist(child): + # result.append(models.Ref.playlist( + # name=name, + # uri='m3u:%s' % child)) + # else: + result.append(models.Ref.track(name=name, uri=uri)) + else: + logger.warn(u'Ignored file: %s' % child.decode(encoding, + 'replace')) + pass + + result.sort(key=operator.attrgetter('name')) + return result + + def lookup(self, uri): + logger.debug(u'looking up uri = %s' % uri) + localpath = path.uri_to_path(uri) + if not self._is_in_basedir(localpath): + logger.warn(u'Not in basedir: %s' % localpath) + return [] + # import pdb; pdb.set_trace() + try: + result = self._scanner.scan(uri) + track = utils.convert_tags_to_track(result.tags).copy( + uri=uri, length=result.duration) + except exceptions.ScannerError as e: + logger.warning(u'Problem looking up %s: %s', uri, e) + track = models.Track(uri=uri) + pass + if not track.name: + filename = os.path.basename(localpath) + name = urllib2.unquote(filename).decode('ascii', 'ignore') + track = track.copy(name=name) + return [track] + + # TODO: get_images that can pull from metadata and/or .folder.png etc? + + def _show_media_dirs(self): + result = [] + for media_dir in self._media_dirs: + dir = models.Ref.directory( + name=media_dir['name'], + uri=path.path_to_uri(media_dir['path'])) + result.append(dir) + return result + + def _check_audiofile(self, uri): + try: + result = self._scanner.scan(uri) + logger.debug(u'got scan result playable: %s for %s' % ( + result.uri, str(result.playable))) + res = result.playable + except exceptions.ScannerError as e: + logger.warning(u'Problem looking up %s: %s', uri, e) + res = False + return res + + def _is_playlist(self, child): + return os.path.splitext(child)[1] == '.m3u' + + def _is_in_basedir(self, localpath): + res = False + basedirs = [mdir['path'] for mdir in self._media_dirs] + for basedir in basedirs: + if basedir == localpath: + res = True + else: + try: + path.check_file_path_is_inside_base_dir(localpath, basedir) + res = True + except: + pass + if not res: + logger.warn(u'%s not inside any basedir' % localpath) + return res diff --git a/mopidy/stream/ext.conf b/mopidy/stream/ext.conf index cedb3085..928ccc63 100644 --- a/mopidy/stream/ext.conf +++ b/mopidy/stream/ext.conf @@ -1,7 +1,6 @@ [stream] enabled = true protocols = - file http https mms diff --git a/setup.py b/setup.py index 9f33236f..ec302548 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ setup( 'mopidy.ext': [ 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', + 'files = mopidy.files:Extension', 'm3u = mopidy.m3u:Extension', 'mpd = mopidy.mpd:Extension', 'softwaremixer = mopidy.softwaremixer:Extension', From 2c587edf7ade49d0bee358e46bc0b91e1a448fc2 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Wed, 1 Jul 2015 10:33:51 +0200 Subject: [PATCH 241/318] file-browser: Changed as discussed in PR 1207 --- mopidy/files/ext.conf | 3 +-- mopidy/files/library.py | 19 +++++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/mopidy/files/ext.conf b/mopidy/files/ext.conf index 8068b528..59cc362d 100644 --- a/mopidy/files/ext.conf +++ b/mopidy/files/ext.conf @@ -1,8 +1,7 @@ [files] enabled = true media_dir = - ~/:Home - /data/music/music_data:Music + $XDG_MUSIC_DIR show_hidden = false follow_symlinks = true metadata_timeout = 1000 \ No newline at end of file diff --git a/mopidy/files/library.py b/mopidy/files/library.py index cad27638..0128cd85 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -17,6 +17,8 @@ logger = logging.getLogger(__name__) class FilesLibraryProvider(backend.LibraryProvider): """Library for browsing local files.""" + # TODO: get_images that can pull from metadata and/or .folder.png etc? + # TODO: handle playlists? @property def root_directory(self): @@ -32,7 +34,6 @@ class FilesLibraryProvider(backend.LibraryProvider): def __init__(self, backend, config): super(FilesLibraryProvider, self).__init__(backend) self._media_dirs = [] - # import pdb; pdb.set_trace() for entry in config['files']['media_dir']: media_dir = {} media_dict = entry.split(':') @@ -48,15 +49,13 @@ class FilesLibraryProvider(backend.LibraryProvider): else: media_dir['name'] = media_dict[0].replace(os.sep, '+') self._media_dirs.append(media_dir) - logger.debug(self._media_dirs) self._follow_symlinks = config['files']['follow_symlinks'] self._show_hidden = config['files']['show_hidden'] self._scanner = scan.Scanner( timeout=config['files']['metadata_timeout']) - def browse(self, uri, encoding=sys.getfilesystemencoding()): + def browse(self, uri): logger.debug(u'browse called with uri %s' % uri) - # import pdb; pdb.set_trace() result = [] localpath = path.uri_to_path(uri) if localpath == 'root': @@ -78,15 +77,10 @@ class FilesLibraryProvider(backend.LibraryProvider): elif stat.S_ISDIR(st.st_mode): result.append(models.Ref.directory(name=name, uri=uri)) elif stat.S_ISREG(st.st_mode) and self._check_audiofile(uri): - # if self._is_playlist(child): - # result.append(models.Ref.playlist( - # name=name, - # uri='m3u:%s' % child)) - # else: result.append(models.Ref.track(name=name, uri=uri)) else: - logger.warn(u'Ignored file: %s' % child.decode(encoding, - 'replace')) + logger.warn(u'Ignored file: %s' % child.decode('ascii', + 'ignore')) pass result.sort(key=operator.attrgetter('name')) @@ -98,7 +92,6 @@ class FilesLibraryProvider(backend.LibraryProvider): if not self._is_in_basedir(localpath): logger.warn(u'Not in basedir: %s' % localpath) return [] - # import pdb; pdb.set_trace() try: result = self._scanner.scan(uri) track = utils.convert_tags_to_track(result.tags).copy( @@ -113,8 +106,6 @@ class FilesLibraryProvider(backend.LibraryProvider): track = track.copy(name=name) return [track] - # TODO: get_images that can pull from metadata and/or .folder.png etc? - def _show_media_dirs(self): result = [] for media_dir in self._media_dirs: From 33511d4400e7a5e1e64aec967c79043e66b0eaf5 Mon Sep 17 00:00:00 2001 From: tom roth Date: Fri, 3 Jul 2015 10:44:31 +0200 Subject: [PATCH 242/318] file-browser: Don't rely on configured media dir to be available --- mopidy/files/ext.conf | 3 ++- mopidy/files/library.py | 39 +++++++++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/mopidy/files/ext.conf b/mopidy/files/ext.conf index 59cc362d..9bc229ff 100644 --- a/mopidy/files/ext.conf +++ b/mopidy/files/ext.conf @@ -1,7 +1,8 @@ [files] enabled = true media_dir = - $XDG_MUSIC_DIR + $XDG_MUSIC_DIR:Music + ~/:Home show_hidden = false follow_symlinks = true metadata_timeout = 1000 \ No newline at end of file diff --git a/mopidy/files/library.py b/mopidy/files/library.py index 0128cd85..8bd04272 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -37,18 +37,29 @@ class FilesLibraryProvider(backend.LibraryProvider): for entry in config['files']['media_dir']: media_dir = {} media_dict = entry.split(':') - local_path = path.expand_path( - media_dict[0].encode(sys.getfilesystemencoding())) - st = os.stat(local_path) - if not stat.S_ISDIR(st.st_mode): - logger.warn(u'%s is not a directory' % local_path) + try: + local_path = path.expand_path( + media_dict[0].encode(sys.getfilesystemencoding())) + except: + pass + if not local_path: + logger.warn('Could not expand path %s' % media_dict[0]) continue - media_dir['path'] = local_path - if len(media_dict) == 2: - media_dir['name'] = media_dict[1] else: - media_dir['name'] = media_dict[0].replace(os.sep, '+') - self._media_dirs.append(media_dir) + try: + st = os.stat(local_path) + except: + logger.warn('Could not open %s' % local_path) + continue + if not stat.S_ISDIR(st.st_mode): + logger.warn(u'%s is not a directory' % local_path) + continue + media_dir['path'] = local_path + if len(media_dict) == 2: + media_dir['name'] = media_dict[1] + else: + media_dir['name'] = media_dict[0].replace(os.sep, '+') + self._media_dirs.append(media_dir) self._follow_symlinks = config['files']['follow_symlinks'] self._show_hidden = config['files']['show_hidden'] self._scanner = scan.Scanner( @@ -81,7 +92,7 @@ class FilesLibraryProvider(backend.LibraryProvider): else: logger.warn(u'Ignored file: %s' % child.decode('ascii', 'ignore')) - pass + continue result.sort(key=operator.attrgetter('name')) return result @@ -118,11 +129,11 @@ class FilesLibraryProvider(backend.LibraryProvider): def _check_audiofile(self, uri): try: result = self._scanner.scan(uri) - logger.debug(u'got scan result playable: %s for %s' % ( - result.uri, str(result.playable))) + logger.debug(u'got scan result playable: %s for %s' % + (result.uri, str(result.playable))) res = result.playable except exceptions.ScannerError as e: - logger.warning(u'Problem looking up %s: %s', uri, e) + logger.warning(u'Problem scanning %s: %s', uri, e) res = False return res From 307a879a901e801a48daf97f071b072b4adcff3e Mon Sep 17 00:00:00 2001 From: tom roth Date: Fri, 3 Jul 2015 13:34:26 +0200 Subject: [PATCH 243/318] file-browser: added some documentation --- docs/ext/backends.rst | 4 ++++ docs/ext/files.rst | 48 +++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 3 files changed, 53 insertions(+) create mode 100644 docs/ext/files.rst diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 1ab00005..19f59806 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -65,6 +65,10 @@ https://github.com/tkem/mopidy-dleyna Provides a backend for playing music from Digital Media Servers using the `dLeyna `_ D-Bus interface. +Mopidy-Files +============ + +Bundled with Mopidy. See :ref:`ext-files`. Mopidy-Grooveshark ================== diff --git a/docs/ext/files.rst b/docs/ext/files.rst new file mode 100644 index 00000000..a4508689 --- /dev/null +++ b/docs/ext/files.rst @@ -0,0 +1,48 @@ +.. _ext-files: + +************ +Mopidy-Files +************ + +Mopidy-Files is an extension for playing music from your local music archive. +It is bundled with Mopidy and enabled by default. +It allows you to browse through your local file system. +Only files that are considered playable will be shown + +This backend handles URIs starting with ``file:``. + + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/files/ext.conf + :language: ini + +.. confval:: files/enabled + + If the files extension should be enabled or not. + +.. confval:: files/media_dir + + A list of directories to be browsable. + Each Directory path has to be written in a separate line. + Optionally the path can be followed by : and a name that will be shown for that path. + +.. confval:: files/show_hidden + + Whether to show hidden files and directories that start with a dot. + Default is false. + +.. confval:: files/follow_symlinks + + Whether to follow symbolic links found in :confval:`files/media_dir`. + Directories and files that are outside the configured media_dirs will not be shown. + Default is false + +.. confval:: files/metadata_timeout + + Number of milliseconds before giving up scanning a file and moving on to + the next file. Reducing the value might speed up the directory listing, + but can lead to some tracks not being shown. diff --git a/docs/index.rst b/docs/index.rst index 3a2998d5..8d621d26 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,6 +96,7 @@ Extensions :maxdepth: 2 ext/local + ext/files ext/m3u ext/stream ext/http From 1e46e9a94dcdf42b2f93ffd033c9a458795d3c94 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Sun, 5 Jul 2015 13:17:39 +0100 Subject: [PATCH 244/318] mpd:Update protocol version to 0.19 Since mpd 0.19, it has concatenated multiple values using a ';' character. Mopidy has been using ', '. This makes mopidy use a ';' for all artist-related values. In mpd 0.18, multiple values were displayed as multiple lines in the output, hence this change bumps the mpd protocol version to 0.19 to reflect the new behaviour. --- mopidy/mpd/protocol/__init__.py | 4 +-- mopidy/mpd/translator.py | 57 +++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index e6b88dbd..b69d5a2a 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -22,8 +22,8 @@ ENCODING = 'UTF-8' #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = '\n' -#: The MPD protocol version is 0.17.0. -VERSION = '0.17.0' +#: The MPD protocol version is 0.19.0. +VERSION = '0.19.0' def load_protocol_modules(): diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 8359f86b..49af8e5e 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -37,7 +37,7 @@ def track_to_mpd_format(track, position=None, stream_title=None): # TODO: only show length if not none, see: # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 ('Time', track.length and (track.length // 1000) or 0), - ('Artist', artists_to_mpd_format(track.artists)), + ('Artist', concatenate_multiple_values(track.artists, 'name')), ('Title', track.name or ''), ('Album', track.album and track.album.name or ''), ] @@ -58,26 +58,37 @@ def track_to_mpd_format(track, position=None, stream_title=None): result.append(('Id', tlid)) if track.album is not None and track.album.musicbrainz_id is not None: result.append(('MUSICBRAINZ_ALBUMID', track.album.musicbrainz_id)) - # FIXME don't use first and best artist? - # FIXME don't duplicate following code? + if track.album is not None and track.album.artists: - artists = artists_to_mpd_format(track.album.artists) - result.append(('AlbumArtist', artists)) - artists = [ - a for a in track.album.artists if a.musicbrainz_id is not None] - if artists: - result.append( - ('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id)) + result.append( + ('AlbumArtist', + concatenate_multiple_values(track.album.artists, 'name'))) + musicbrainz_ids = concatenate_multiple_values( + track.album.artists, 'musicbrainz_id') + if musicbrainz_ids: + result.append(('MUSICBRAINZ_ALBUMARTISTID', musicbrainz_ids)) + if track.artists: - artists = [a for a in track.artists if a.musicbrainz_id is not None] - if artists: - result.append(('MUSICBRAINZ_ARTISTID', artists[0].musicbrainz_id)) + musicbrainz_ids = concatenate_multiple_values( + track.artists, 'musicbrainz_id') + if musicbrainz_ids: + result.append(('MUSICBRAINZ_ARTISTID', musicbrainz_ids)) if track.composers: - result.append(('Composer', artists_to_mpd_format(track.composers))) + result.append( + ( + 'Composer', + concatenate_multiple_values(track.composers, 'name') + ) + ) if track.performers: - result.append(('Performer', artists_to_mpd_format(track.performers))) + result.append( + ( + 'Performer', + concatenate_multiple_values(track.performers, 'name') + ) + ) if track.genre: result.append(('Genre', track.genre)) @@ -90,17 +101,23 @@ def track_to_mpd_format(track, position=None, stream_title=None): return result -def artists_to_mpd_format(artists): +def concatenate_multiple_values(artists, attribute): """ - Format track artists for output to MPD client. + Format track artist values for output to MPD client. :param artists: the artists :type track: array of :class:`mopidy.models.Artist` + :param attribute: the artist attribute to use + :type string :rtype: string """ - artists = list(artists) - artists.sort(key=lambda a: a.name) - return ', '.join([a.name for a in artists if a.name]) + # Don't sort the values. MPD doesn't appear to (or if it does it's not + # strict alphabetical). If we just use them in the order in which they come + # in then the musicbrainz ids have a higher chance of staying in sync + return ';'.join( + getattr(a, attribute) + for a in artists if getattr(a, attribute, None) is not None + ) def tracks_to_mpd_format(tracks, start=0, end=None): From a2f2d5f1670ff9ea7a0790dff9845a56ec103566 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Sun, 5 Jul 2015 13:40:45 +0100 Subject: [PATCH 245/318] mpd:Update protocol to 0.19 Update tests to reflect new function names --- tests/mpd/test_translator.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 646a22b2..4127eaa2 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -103,14 +103,20 @@ class TrackMpdFormatTest(unittest.TestCase): result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) - def test_artists_to_mpd_format(self): + def test_concatenate_multiple_values(self): artists = [Artist(name='ABBA'), Artist(name='Beatles')] - translated = translator.artists_to_mpd_format(artists) - self.assertEqual(translated, 'ABBA, Beatles') + translated = translator.concatenate_multiple_values(artists, 'name') + self.assertEqual(translated, 'ABBA;Beatles') - def test_artists_to_mpd_format_artist_with_no_name(self): + def test_concatenate_muultiple_values_artist_with_no_name(self): artists = [Artist(name=None)] - translated = translator.artists_to_mpd_format(artists) + translated = translator.concatenate_multiple_values(artists, 'name') + self.assertEqual(translated, '') + + def test_concatenate_muultiple_values_artist_with_no_musicbrainz_id(self): + artists = [Artist(name="Jah Wobble")] + translated = translator.concatenate_multiple_values( + artists, 'musicbrainz_id') self.assertEqual(translated, '') From dc7c787e4787abc1c9cff3d87c98cc165d1a4406 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 5 Jul 2015 18:14:34 +0200 Subject: [PATCH 246/318] docs: Add versionadded to httpclient functions --- mopidy/httpclient.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/httpclient.py b/mopidy/httpclient.py index 54f39ca3..682a78bd 100644 --- a/mopidy/httpclient.py +++ b/mopidy/httpclient.py @@ -15,6 +15,8 @@ def format_proxy(proxy_config, auth=True): You can also opt out of getting the basic auth by setting ``auth`` to :class:`False`. + + .. versionadded:: 1.1 """ if not proxy_config.get('hostname'): return None @@ -39,6 +41,8 @@ def format_user_agent(name=None): This will identify use by the provided ``name`` (which should be on the format ``dist_name/version``), Mopidy version and Python version. + + .. versionadded:: 1.1 """ parts = ['Mopidy/%s' % (mopidy.__version__), '%s/%s' % (platform.python_implementation(), From ab7e463af0d898331ac2a17bfad8594be7e9d57e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 5 Jul 2015 18:18:34 +0200 Subject: [PATCH 247/318] docs: Remove trailing whitespace --- docs/extensiondev.rst | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 1e25f48b..b50e4a7c 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -6,7 +6,7 @@ Extension development Mopidy started as simply an MPD server that could play music from Spotify. Early on, Mopidy got multiple "frontends" to expose Mopidy to more than just MPD -clients: for example the scrobbler frontend that scrobbles your listening +clients: for example the scrobbler frontend that scrobbles your listening history to your Last.fm account, the MPRIS frontend that integrates Mopidy into the Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web based Mopidy clients possible. In Mopidy 0.9 we added support for multiple @@ -75,10 +75,10 @@ the readme of `cookiecutter-mopidy-ext Example README.rst ================== -The README file should quickly explain what the extension does, how to install -it, and how to configure it. It should also contain a link to a tarball of the -latest development version of the extension. It's important that this link ends -with ``#egg=Mopidy-Something-dev`` for installation using +The README file should quickly explain what the extension does, how to install +it, and how to configure it. It should also contain a link to a tarball of the +latest development version of the extension. It's important that this link ends +with ``#egg=Mopidy-Something-dev`` for installation using ``pip install Mopidy-Something==dev`` to work. .. code-block:: rst @@ -230,8 +230,8 @@ The root of your Python package should have an ``__version__`` attribute with a class named ``Extension`` which inherits from Mopidy's extension base class, :class:`mopidy.ext.Extension`. This is the class referred to in the ``entry_points`` part of ``setup.py``. Any imports of other files in your -extension, outside of Mopidy and it's core requirements, should be kept inside -methods. This ensures that this file can be imported without raising +extension, outside of Mopidy and it's core requirements, should be kept inside +methods. This ensures that this file can be imported without raising :exc:`ImportError` exceptions for missing dependencies, etc. The default configuration for the extension is defined by the @@ -245,7 +245,7 @@ change them. The exception is if the config value has security implications; in that case you should default to the most secure configuration. Leave any configurations that don't have meaningful defaults blank, like ``username`` and ``password``. In the example below, we've chosen to maintain the default -config as a separate file named ``ext.conf``. This makes it easy to include the +config as a separate file named ``ext.conf``. This makes it easy to include the default config in documentation without duplicating it. This is ``mopidy_soundspot/__init__.py``:: @@ -413,11 +413,11 @@ examples, see the :ref:`http-server-api` docs or explore with Running an extension ==================== -Once your extension is ready to go, to see it in action you'll need to register -it with Mopidy. Typically this is done by running ``python setup.py install`` -from your extension's Git repo root directory. While developing your extension -and to avoid doing this every time you make a change, you can instead run -``python setup.py develop`` to effectively link Mopidy directly with your +Once your extension is ready to go, to see it in action you'll need to register +it with Mopidy. Typically this is done by running ``python setup.py install`` +from your extension's Git repo root directory. While developing your extension +and to avoid doing this every time you make a change, you can instead run +``python setup.py develop`` to effectively link Mopidy directly with your development files. From 6673ae4308953a0ec9dc260833fd428788c28a38 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 5 Jul 2015 19:08:32 +0200 Subject: [PATCH 248/318] docs: Add HTTP request recommendations for extensions Related to #1156 --- docs/extensiondev.rst | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index b50e4a7c..034e0b54 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -471,3 +471,76 @@ Is much better than:: If you want to turn on debug logging for your own extension, but not for everything else due to the amount of noise, see the docs for the :confval:`loglevels/*` config section. + + +Making HTTP requests from extensions +==================================== + +Many Mopidy extensions need to make HTTP requests to use some web API. Here's a +few recommendations to those extensions. + +Proxies +------- + +If you make HTTP requests please make sure to respect the :ref:`proxy configs +`, so that all the requests you make go through the proxy +configured by the Mopidy user. To make this easier for extension developers, +the helper function :func:`mopidy.httpclient.format_proxy` was added in Mopidy +1.1. This function returns the proxy settings `formatted the way Requests +expects `__. + +User-Agent strings +------------------ + +When you make HTTP requests, it's helpful for debugging and usage analysis if +the client identifies itself with a proper User-Agent string. In Mopidy 1.1, we +added the helper function :func:`mopidy.httpclient.format_user_agent`. Here's +an example of how to use it:: + + >>> from mopidy import httpclient + >>> import mopidy_soundspot + >>> httpclient.format_user_agent('%s/%s' % ( + ... mopidy_soundspot.Extension.dist_name, mopidy_soundspot.__version__)) + u'Mopidy-SoundSpot/2.0.0 Mopidy/1.0.7 Python/2.7.10' + +Example using Requests sessions +------------------------------- + +Most Mopidy extensions that make HTTP requests use the `Requests +`_ library to do so. When using Requests, the +most convenient way to make sure the proxy and User-Agent header is set +properly is to create a Requests session object and use that object to make all +your HTTP requests:: + + from mopidy import httpclient + + import requests + + import mopidy_soundspot + + + def get_requests_session(proxy_config, user_agent): + proxy = httpclient.format_proxy(proxy_config) + full_user_agent = httpclient.format_user_agent(user_agent) + + session = requests.Session() + session.proxies.update({'http': proxy, 'https': proxy}) + session.headers.update({'user-agent': full_user_agent}) + + return session + + + # ``mopidy_config`` is the config object passed to your frontend/backend + # constructor + session = get_requests_session( + proxy_config=mopidy_config['proxy'], + user_agent='%s/%s' % ( + mopidy_soundspot.Extension.dist_name, + mopidy_soundspot.__version__)) + + response = session.get('http://example.com') + # Now do something with ``response`` and/or make further requests using the + # ``session`` object. + +For further details, see Requests' docs on `session objects +`__. From f3f140c19f3f15422a6ab6b80a9568f4e38eec00 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Mon, 6 Jul 2015 09:26:52 +0100 Subject: [PATCH 249/318] mpd:Update protocol to 0.19 Address issues raised in review: Fix formatting by shortening function name to concat_multi_values Change comments and variable names to reflect generic nature of function Fix typos in tests Default to single quotes for strings --- mopidy/mpd/translator.py | 39 ++++++++++++++---------------------- tests/mpd/test_translator.py | 15 +++++++------- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 49af8e5e..2cd9e3ad 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -37,7 +37,7 @@ def track_to_mpd_format(track, position=None, stream_title=None): # TODO: only show length if not none, see: # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 ('Time', track.length and (track.length // 1000) or 0), - ('Artist', concatenate_multiple_values(track.artists, 'name')), + ('Artist', concat_multi_values(track.artists, 'name')), ('Title', track.name or ''), ('Album', track.album and track.album.name or ''), ] @@ -61,34 +61,24 @@ def track_to_mpd_format(track, position=None, stream_title=None): if track.album is not None and track.album.artists: result.append( - ('AlbumArtist', - concatenate_multiple_values(track.album.artists, 'name'))) - musicbrainz_ids = concatenate_multiple_values( + ('AlbumArtist', concat_multi_values(track.album.artists, 'name'))) + musicbrainz_ids = concat_multi_values( track.album.artists, 'musicbrainz_id') if musicbrainz_ids: result.append(('MUSICBRAINZ_ALBUMARTISTID', musicbrainz_ids)) if track.artists: - musicbrainz_ids = concatenate_multiple_values( - track.artists, 'musicbrainz_id') + musicbrainz_ids = concat_multi_values(track.artists, 'musicbrainz_id') if musicbrainz_ids: result.append(('MUSICBRAINZ_ARTISTID', musicbrainz_ids)) if track.composers: result.append( - ( - 'Composer', - concatenate_multiple_values(track.composers, 'name') - ) - ) + ('Composer', concat_multi_values(track.composers, 'name'))) if track.performers: result.append( - ( - 'Performer', - concatenate_multiple_values(track.performers, 'name') - ) - ) + ('Performer', concat_multi_values(track.performers, 'name'))) if track.genre: result.append(('Genre', track.genre)) @@ -101,22 +91,23 @@ def track_to_mpd_format(track, position=None, stream_title=None): return result -def concatenate_multiple_values(artists, attribute): +def concat_multi_values(models, attribute): """ - Format track artist values for output to MPD client. + Format mopidy model values for output to MPD client. - :param artists: the artists - :type track: array of :class:`mopidy.models.Artist` - :param attribute: the artist attribute to use - :type string + :param models: the models + :type models: array of :class:`mopidy.models.Artist`, + :class:`mopidy.models.Album` or :class:`mopidy.models.Track` + :param attribute: the attribute to use + :type attribute: string :rtype: string """ # Don't sort the values. MPD doesn't appear to (or if it does it's not # strict alphabetical). If we just use them in the order in which they come # in then the musicbrainz ids have a higher chance of staying in sync return ';'.join( - getattr(a, attribute) - for a in artists if getattr(a, attribute, None) is not None + getattr(m, attribute) + for m in models if getattr(m, attribute, None) is not None ) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 4127eaa2..99c87dad 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -103,20 +103,19 @@ class TrackMpdFormatTest(unittest.TestCase): result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) - def test_concatenate_multiple_values(self): + def test_concat_multi_values(self): artists = [Artist(name='ABBA'), Artist(name='Beatles')] - translated = translator.concatenate_multiple_values(artists, 'name') + translated = translator.concat_multi_values(artists, 'name') self.assertEqual(translated, 'ABBA;Beatles') - def test_concatenate_muultiple_values_artist_with_no_name(self): + def test_concat_multi_values_artist_with_no_name(self): artists = [Artist(name=None)] - translated = translator.concatenate_multiple_values(artists, 'name') + translated = translator.concat_multi_values(artists, 'name') self.assertEqual(translated, '') - def test_concatenate_muultiple_values_artist_with_no_musicbrainz_id(self): - artists = [Artist(name="Jah Wobble")] - translated = translator.concatenate_multiple_values( - artists, 'musicbrainz_id') + def test_concat_multi_values_artist_with_no_musicbrainz_id(self): + artists = [Artist(name='Jah Wobble')] + translated = translator.concat_multi_values(artists, 'musicbrainz_id') self.assertEqual(translated, '') From 1f3a4abab0a1eb3018dfa724425d4a6b858cabda Mon Sep 17 00:00:00 2001 From: tom roth Date: Mon, 6 Jul 2015 11:11:23 +0200 Subject: [PATCH 250/318] file-browser: Various changes as discussed in PR 1207 --- docs/ext/files.rst | 13 ++-- mopidy/files/__init__.py | 2 +- mopidy/files/ext.conf | 8 +-- mopidy/files/library.py | 126 +++++++++++++++++++-------------------- 4 files changed, 72 insertions(+), 77 deletions(-) diff --git a/docs/ext/files.rst b/docs/ext/files.rst index a4508689..c952ba14 100644 --- a/docs/ext/files.rst +++ b/docs/ext/files.rst @@ -7,7 +7,7 @@ Mopidy-Files Mopidy-Files is an extension for playing music from your local music archive. It is bundled with Mopidy and enabled by default. It allows you to browse through your local file system. -Only files that are considered playable will be shown +Only files that are considered playable will be shown. This backend handles URIs starting with ``file:``. @@ -27,10 +27,9 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: files/media_dir A list of directories to be browsable. - Each Directory path has to be written in a separate line. - Optionally the path can be followed by : and a name that will be shown for that path. + Optionally the path can be followed by | and a name that will be shown for that path. -.. confval:: files/show_hidden +.. confval:: files/show_dotfiles Whether to show hidden files and directories that start with a dot. Default is false. @@ -38,11 +37,11 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: files/follow_symlinks Whether to follow symbolic links found in :confval:`files/media_dir`. - Directories and files that are outside the configured media_dirs will not be shown. - Default is false + Directories and files that are outside the configured directories will not be shown. + Default is false. .. confval:: files/metadata_timeout Number of milliseconds before giving up scanning a file and moving on to the next file. Reducing the value might speed up the directory listing, - but can lead to some tracks not being shown. + but can lead to some tracks not being shown. Must be larger than 1000. diff --git a/mopidy/files/__init__.py b/mopidy/files/__init__.py index cd5e41c1..1e2e961a 100644 --- a/mopidy/files/__init__.py +++ b/mopidy/files/__init__.py @@ -22,7 +22,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['media_dir'] = config.List(optional=True) - schema['show_hidden'] = config.Boolean(optional=True) + schema['show_dotfiles'] = config.Boolean(optional=True) schema['follow_symlinks'] = config.Boolean(optional=True) schema['metadata_timeout'] = config.Integer( minimum=1000, maximum=1000 * 60 * 60, optional=True) diff --git a/mopidy/files/ext.conf b/mopidy/files/ext.conf index 9bc229ff..836db665 100644 --- a/mopidy/files/ext.conf +++ b/mopidy/files/ext.conf @@ -1,8 +1,8 @@ [files] enabled = true media_dir = - $XDG_MUSIC_DIR:Music - ~/:Home -show_hidden = false + $XDG_MUSIC_DIR|Music + ~/|Home +show_dotfiles = false follow_symlinks = true -metadata_timeout = 1000 \ No newline at end of file +metadata_timeout = 1000 diff --git a/mopidy/files/library.py b/mopidy/files/library.py index 8bd04272..09d898ae 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -7,7 +7,6 @@ import stat import sys import urllib2 - from mopidy import backend, exceptions, models from mopidy.audio import scan, utils from mopidy.internal import path @@ -33,75 +32,48 @@ class FilesLibraryProvider(backend.LibraryProvider): def __init__(self, backend, config): super(FilesLibraryProvider, self).__init__(backend) - self._media_dirs = [] - for entry in config['files']['media_dir']: - media_dir = {} - media_dict = entry.split(':') - try: - local_path = path.expand_path( - media_dict[0].encode(sys.getfilesystemencoding())) - except: - pass - if not local_path: - logger.warn('Could not expand path %s' % media_dict[0]) - continue - else: - try: - st = os.stat(local_path) - except: - logger.warn('Could not open %s' % local_path) - continue - if not stat.S_ISDIR(st.st_mode): - logger.warn(u'%s is not a directory' % local_path) - continue - media_dir['path'] = local_path - if len(media_dict) == 2: - media_dir['name'] = media_dict[1] - else: - media_dir['name'] = media_dict[0].replace(os.sep, '+') - self._media_dirs.append(media_dir) + self._media_dirs = list(self._get_media_dirs(config)) self._follow_symlinks = config['files']['follow_symlinks'] - self._show_hidden = config['files']['show_hidden'] + self._show_dotfiles = config['files']['show_dotfiles'] self._scanner = scan.Scanner( timeout=config['files']['metadata_timeout']) def browse(self, uri): - logger.debug(u'browse called with uri %s' % uri) + logger.debug('browse called with uri %s', uri) result = [] localpath = path.uri_to_path(uri) if localpath == 'root': - result = self._show_media_dirs() - else: - if not self._is_in_basedir(localpath): - logger.warn(u'Not in basedir: %s' % localpath) - return [] - for name in os.listdir(localpath): - child = os.path.join(localpath, name) - uri = path.path_to_uri(child) - name = name.decode('ascii', 'ignore') - if self._follow_symlinks: - st = os.stat(child) - else: - st = os.lstat(child) - if not self._show_hidden and name.startswith(b'.'): - continue - elif stat.S_ISDIR(st.st_mode): - result.append(models.Ref.directory(name=name, uri=uri)) - elif stat.S_ISREG(st.st_mode) and self._check_audiofile(uri): - result.append(models.Ref.track(name=name, uri=uri)) - else: - logger.warn(u'Ignored file: %s' % child.decode('ascii', - 'ignore')) - continue - + return list(self._get_media_dirs_refs()) + if not self._is_in_basedir(localpath): + logger.warn(u'Not in basedir: %s', localpath) + return [] + for name in os.listdir(localpath): + child = os.path.join(localpath, name) + uri = path.path_to_uri(child) + name = name.decode(sys.getfilesystemencoding(), 'ignore') + if not self._show_dotfiles and name.startswith(b'.'): + continue + if self._follow_symlinks: + st = os.stat(child) + else: + st = os.lstat(child) + if stat.S_ISDIR(st.st_mode): + result.append(models.Ref.directory(name=name, uri=uri)) + elif stat.S_ISREG(st.st_mode) and self._check_audiofile(uri): + result.append(models.Ref.track(name=name, uri=uri)) + else: + logger.warn('Ignored file: %s', + child.decode(sys.getfilesystemencoding(), + 'ignore')) + continue result.sort(key=operator.attrgetter('name')) return result def lookup(self, uri): - logger.debug(u'looking up uri = %s' % uri) + logger.debug(u'looking up uri = %s', uri) localpath = path.uri_to_path(uri) if not self._is_in_basedir(localpath): - logger.warn(u'Not in basedir: %s' % localpath) + logger.warn(u'Not in basedir: %s', localpath) return [] try: result = self._scanner.scan(uri) @@ -110,13 +82,38 @@ class FilesLibraryProvider(backend.LibraryProvider): except exceptions.ScannerError as e: logger.warning(u'Problem looking up %s: %s', uri, e) track = models.Track(uri=uri) - pass if not track.name: filename = os.path.basename(localpath) - name = urllib2.unquote(filename).decode('ascii', 'ignore') + name = urllib2.unquote(filename).decode( + sys.getfilesystemencoding(), 'ignore') track = track.copy(name=name) return [track] + def _get_media_dirs(self, config): + for entry in config['files']['media_dir']: + media_dir = {} + media_dir_split = entry.split('|', 1) + local_path = path.expand_path( + media_dir_split[0].encode(sys.getfilesystemencoding())) + if not local_path: + logger.warn('Could not expand path %s', media_dir_split[0]) + continue + elif not os.path.isdir(local_path): + logger.warn('%s is not a directory', local_path) + continue + media_dir['path'] = local_path + if len(media_dir_split) == 2: + media_dir['name'] = media_dir_split[1] + else: + media_dir['name'] = media_dir_split[0].replace(os.sep, '+') + yield media_dir + + def _get_media_dirs_refs(self): + for media_dir in self._media_dirs: + yield models.Ref.directory( + name=media_dir['name'], + uri=path.path_to_uri(media_dir['path'])) + def _show_media_dirs(self): result = [] for media_dir in self._media_dirs: @@ -129,13 +126,12 @@ class FilesLibraryProvider(backend.LibraryProvider): def _check_audiofile(self, uri): try: result = self._scanner.scan(uri) - logger.debug(u'got scan result playable: %s for %s' % - (result.uri, str(result.playable))) - res = result.playable + logger.debug('got scan result playable: %s for %s', + result.uri, str(result.playable)) + return result.playable except exceptions.ScannerError as e: - logger.warning(u'Problem scanning %s: %s', uri, e) - res = False - return res + logger.warning('Problem scanning %s: %s', uri, e) + return False def _is_playlist(self, child): return os.path.splitext(child)[1] == '.m3u' @@ -153,5 +149,5 @@ class FilesLibraryProvider(backend.LibraryProvider): except: pass if not res: - logger.warn(u'%s not inside any basedir' % localpath) + logger.warn('%s not inside any basedir', localpath) return res From 759261d1d0cc36112735be922821b70d89524ef0 Mon Sep 17 00:00:00 2001 From: tom roth Date: Mon, 6 Jul 2015 15:01:32 +0200 Subject: [PATCH 251/318] file-browser: is_local_path_inside_base_dir checks on dirs to --- mopidy/files/library.py | 59 +++++++++++++++-------------------------- mopidy/internal/path.py | 24 ++++++++--------- 2 files changed, 33 insertions(+), 50 deletions(-) diff --git a/mopidy/files/library.py b/mopidy/files/library.py index 09d898ae..bea8c062 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -24,8 +24,8 @@ class FilesLibraryProvider(backend.LibraryProvider): if not self._media_dirs: return None elif len(self._media_dirs) == 1: - localpath = self._media_dirs[0]['path'] - uri = path.path_to_uri(localpath) + local_path = self._media_dirs[0]['path'] + uri = path.path_to_uri(local_path) else: uri = u'file:root' return models.Ref.directory(name='Files', uri=uri) @@ -41,14 +41,15 @@ class FilesLibraryProvider(backend.LibraryProvider): def browse(self, uri): logger.debug('browse called with uri %s', uri) result = [] - localpath = path.uri_to_path(uri) - if localpath == 'root': + local_path = path.uri_to_path(uri) + if local_path == 'root': return list(self._get_media_dirs_refs()) - if not self._is_in_basedir(localpath): - logger.warn(u'Not in basedir: %s', localpath) - return [] - for name in os.listdir(localpath): - child = os.path.join(localpath, name) + for name in os.listdir(local_path): + if not self._is_in_basedir(local_path): + logger.warn(u'Not in base_dir: %s', local_path) + continue + child = os.path.join(local_path, name) + logger.debug('child: %s', child) uri = path.path_to_uri(child) name = name.decode(sys.getfilesystemencoding(), 'ignore') if not self._show_dotfiles and name.startswith(b'.'): @@ -59,7 +60,7 @@ class FilesLibraryProvider(backend.LibraryProvider): st = os.lstat(child) if stat.S_ISDIR(st.st_mode): result.append(models.Ref.directory(name=name, uri=uri)) - elif stat.S_ISREG(st.st_mode) and self._check_audiofile(uri): + elif stat.S_ISREG(st.st_mode) and self._is_audiofile(uri): result.append(models.Ref.track(name=name, uri=uri)) else: logger.warn('Ignored file: %s', @@ -71,9 +72,9 @@ class FilesLibraryProvider(backend.LibraryProvider): def lookup(self, uri): logger.debug(u'looking up uri = %s', uri) - localpath = path.uri_to_path(uri) - if not self._is_in_basedir(localpath): - logger.warn(u'Not in basedir: %s', localpath) + local_path = path.uri_to_path(uri) + if not self._is_in_basedir(local_path): + logger.warn(u'Not in base_dir: %s', local_path) return [] try: result = self._scanner.scan(uri) @@ -83,7 +84,7 @@ class FilesLibraryProvider(backend.LibraryProvider): logger.warning(u'Problem looking up %s: %s', uri, e) track = models.Track(uri=uri) if not track.name: - filename = os.path.basename(localpath) + filename = os.path.basename(local_path) name = urllib2.unquote(filename).decode( sys.getfilesystemencoding(), 'ignore') track = track.copy(name=name) @@ -114,16 +115,7 @@ class FilesLibraryProvider(backend.LibraryProvider): name=media_dir['name'], uri=path.path_to_uri(media_dir['path'])) - def _show_media_dirs(self): - result = [] - for media_dir in self._media_dirs: - dir = models.Ref.directory( - name=media_dir['name'], - uri=path.path_to_uri(media_dir['path'])) - result.append(dir) - return result - - def _check_audiofile(self, uri): + def _is_audiofile(self, uri): try: result = self._scanner.scan(uri) logger.debug('got scan result playable: %s for %s', @@ -133,21 +125,12 @@ class FilesLibraryProvider(backend.LibraryProvider): logger.warning('Problem scanning %s: %s', uri, e) return False - def _is_playlist(self, child): - return os.path.splitext(child)[1] == '.m3u' - - def _is_in_basedir(self, localpath): + def _is_in_basedir(self, local_path): res = False - basedirs = [mdir['path'] for mdir in self._media_dirs] - for basedir in basedirs: - if basedir == localpath: + base_dirs = [mdir['path'] for mdir in self._media_dirs] + for base_dir in base_dirs: + if path.is_local_path_inside_base_dir(local_path, base_dir): res = True - else: - try: - path.check_file_path_is_inside_base_dir(localpath, basedir) - res = True - except: - pass if not res: - logger.warn('%s not inside any basedir', localpath) + logger.warn('%s not inside any base_dir', local_path) return res diff --git a/mopidy/internal/path.py b/mopidy/internal/path.py index 3a41d930..9e642755 100644 --- a/mopidy/internal/path.py +++ b/mopidy/internal/path.py @@ -196,23 +196,23 @@ def find_mtimes(root, follow=False): return mtimes, errors -def check_file_path_is_inside_base_dir(file_path, base_path): - assert not file_path.endswith(os.sep), ( - 'File path %s cannot end with a path separator' % file_path) - +def is_local_path_inside_base_dir(local_path, base_path): + if local_path.endswith(os.sep): + raise ValueError('Local path %s cannot end with a path separator' + % local_path) # Expand symlinks real_base_path = os.path.realpath(base_path) - real_file_path = os.path.realpath(file_path) + real_local_path = os.path.realpath(local_path) - # Use dir of file for prefix comparision, so we don't accept - # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a - # common prefix, /tmp/foo, which matches the base path, /tmp/foo. - real_dir_path = os.path.dirname(real_file_path) + if os.path.isfile(local_path): + # Use dir of file for prefix comparision, so we don't accept + # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a + # common prefix, /tmp/foo, which matches the base path, /tmp/foo. + real_local_path = os.path.dirname(real_local_path) # Check if dir of file is the base path or a subdir - common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) - assert common_prefix == real_base_path, ( - 'File path %s must be in %s' % (real_file_path, real_base_path)) + common_prefix = os.path.commonprefix([real_base_path, real_local_path]) + return common_prefix == real_base_path # FIXME replace with mock usage in tests. From 81af757b095398fa9f64271cccfa47822766f46d Mon Sep 17 00:00:00 2001 From: tom roth Date: Mon, 6 Jul 2015 15:23:05 +0200 Subject: [PATCH 252/318] file-browser: let the user decide on minimal scanner timeout --- docs/ext/files.rst | 2 +- mopidy/files/__init__.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/ext/files.rst b/docs/ext/files.rst index c952ba14..4a8e741e 100644 --- a/docs/ext/files.rst +++ b/docs/ext/files.rst @@ -44,4 +44,4 @@ See :ref:`config` for general help on configuring Mopidy. Number of milliseconds before giving up scanning a file and moving on to the next file. Reducing the value might speed up the directory listing, - but can lead to some tracks not being shown. Must be larger than 1000. + but can lead to some tracks not being shown. diff --git a/mopidy/files/__init__.py b/mopidy/files/__init__.py index 1e2e961a..90ebf7f8 100644 --- a/mopidy/files/__init__.py +++ b/mopidy/files/__init__.py @@ -24,8 +24,7 @@ class Extension(ext.Extension): schema['media_dir'] = config.List(optional=True) schema['show_dotfiles'] = config.Boolean(optional=True) schema['follow_symlinks'] = config.Boolean(optional=True) - schema['metadata_timeout'] = config.Integer( - minimum=1000, maximum=1000 * 60 * 60, optional=True) + schema['metadata_timeout'] = config.Integer(optional=True) return schema def setup(self, registry): From b51e2862d1ef885661454a8055e4e11ac2fa6bc8 Mon Sep 17 00:00:00 2001 From: tom roth Date: Mon, 6 Jul 2015 15:26:06 +0200 Subject: [PATCH 253/318] file-browser: let the user decide on minimal scanner timeout --- mopidy/internal/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/internal/path.py b/mopidy/internal/path.py index 9e642755..7573dfd2 100644 --- a/mopidy/internal/path.py +++ b/mopidy/internal/path.py @@ -199,7 +199,7 @@ def find_mtimes(root, follow=False): def is_local_path_inside_base_dir(local_path, base_path): if local_path.endswith(os.sep): raise ValueError('Local path %s cannot end with a path separator' - % local_path) + % local_path) # Expand symlinks real_base_path = os.path.realpath(base_path) real_local_path = os.path.realpath(local_path) From d8e0099ff45a0ba93750dffd02284ee0e08df4d9 Mon Sep 17 00:00:00 2001 From: tom roth Date: Tue, 7 Jul 2015 08:01:15 +0200 Subject: [PATCH 254/318] file-browser: Changed as discussed in PR 1207 --- docs/ext/files.rst | 4 +-- mopidy/files/__init__.py | 2 +- mopidy/files/ext.conf | 4 +-- mopidy/files/library.py | 56 +++++++++++++++++++++------------------- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/docs/ext/files.rst b/docs/ext/files.rst index 4a8e741e..42120807 100644 --- a/docs/ext/files.rst +++ b/docs/ext/files.rst @@ -24,10 +24,10 @@ See :ref:`config` for general help on configuring Mopidy. If the files extension should be enabled or not. -.. confval:: files/media_dir +.. confval:: files/media_dirs A list of directories to be browsable. - Optionally the path can be followed by | and a name that will be shown for that path. + Optionally the path can be followed by ``|`` and a name that will be shown for that path. .. confval:: files/show_dotfiles diff --git a/mopidy/files/__init__.py b/mopidy/files/__init__.py index 90ebf7f8..d547b256 100644 --- a/mopidy/files/__init__.py +++ b/mopidy/files/__init__.py @@ -21,7 +21,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() - schema['media_dir'] = config.List(optional=True) + schema['media_dirs'] = config.List(optional=True) schema['show_dotfiles'] = config.Boolean(optional=True) schema['follow_symlinks'] = config.Boolean(optional=True) schema['metadata_timeout'] = config.Integer(optional=True) diff --git a/mopidy/files/ext.conf b/mopidy/files/ext.conf index 836db665..afdd1183 100644 --- a/mopidy/files/ext.conf +++ b/mopidy/files/ext.conf @@ -1,8 +1,8 @@ [files] enabled = true -media_dir = +media_dirs = $XDG_MUSIC_DIR|Music ~/|Home show_dotfiles = false -follow_symlinks = true +follow_symlinks = false metadata_timeout = 1000 diff --git a/mopidy/files/library.py b/mopidy/files/library.py index bea8c062..47c58cda 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import logging import operator import os -import stat import sys import urllib2 @@ -27,7 +26,7 @@ class FilesLibraryProvider(backend.LibraryProvider): local_path = self._media_dirs[0]['path'] uri = path.path_to_uri(local_path) else: - uri = u'file:root' + uri = 'file:root' return models.Ref.directory(name='Files', uri=uri) def __init__(self, backend, config): @@ -39,49 +38,52 @@ class FilesLibraryProvider(backend.LibraryProvider): timeout=config['files']['metadata_timeout']) def browse(self, uri): - logger.debug('browse called with uri %s', uri) + logger.debug('Browsing files at: %s', uri) result = [] local_path = path.uri_to_path(uri) if local_path == 'root': return list(self._get_media_dirs_refs()) - for name in os.listdir(local_path): - if not self._is_in_basedir(local_path): - logger.warn(u'Not in base_dir: %s', local_path) + for dir_entry in os.listdir(local_path): + child_path = os.path.join(local_path, dir_entry) + uri = path.path_to_uri(child_path) + printable_path = child_path.decode(sys.getfilesystemencoding(), + 'ignore') + + if os.path.islink(child_path) and not self._follow_symlinks: + logger.debug('Ignoring symlink: %s', printable_path) continue - child = os.path.join(local_path, name) - logger.debug('child: %s', child) - uri = path.path_to_uri(child) - name = name.decode(sys.getfilesystemencoding(), 'ignore') - if not self._show_dotfiles and name.startswith(b'.'): + + if not self._is_in_basedir(os.path.realpath(child_path)): + logger.debug('Ignoring symlink to outside base dir: %s', + printable_path) continue - if self._follow_symlinks: - st = os.stat(child) - else: - st = os.lstat(child) - if stat.S_ISDIR(st.st_mode): - result.append(models.Ref.directory(name=name, uri=uri)) - elif stat.S_ISREG(st.st_mode) and self._is_audiofile(uri): - result.append(models.Ref.track(name=name, uri=uri)) - else: - logger.warn('Ignored file: %s', - child.decode(sys.getfilesystemencoding(), - 'ignore')) + + if not self._show_dotfiles and dir_entry.startswith(b'.'): continue + + if os.path.isdir(child_path): + result.append(models.Ref.directory(name=dir_entry, uri=uri)) + elif os.path.isfile(child_path): + if self._is_audiofile(uri): + result.append(models.Ref.track(name=dir_entry, uri=uri)) + else: + logger.debug('Ignoring non-audiofile: %s', printable_path) + result.sort(key=operator.attrgetter('name')) return result def lookup(self, uri): - logger.debug(u'looking up uri = %s', uri) + logger.debug('looking up uri = %s', uri) local_path = path.uri_to_path(uri) if not self._is_in_basedir(local_path): - logger.warn(u'Not in base_dir: %s', local_path) + logger.warn('Ignoring URI outside base dir: %s', local_path) return [] try: result = self._scanner.scan(uri) track = utils.convert_tags_to_track(result.tags).copy( uri=uri, length=result.duration) except exceptions.ScannerError as e: - logger.warning(u'Problem looking up %s: %s', uri, e) + logger.warning('Problem looking up %s: %s', uri, e) track = models.Track(uri=uri) if not track.name: filename = os.path.basename(local_path) @@ -91,7 +93,7 @@ class FilesLibraryProvider(backend.LibraryProvider): return [track] def _get_media_dirs(self, config): - for entry in config['files']['media_dir']: + for entry in config['files']['media_dirs']: media_dir = {} media_dir_split = entry.split('|', 1) local_path = path.expand_path( From a8085cf29a15da2de694388d90df59e60ad01057 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Wed, 8 Jul 2015 00:23:15 +0200 Subject: [PATCH 255/318] file-browser: changd local_path to path in internal#path --- mopidy/files/library.py | 2 +- mopidy/internal/path.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/mopidy/files/library.py b/mopidy/files/library.py index 47c58cda..42465fab 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -131,7 +131,7 @@ class FilesLibraryProvider(backend.LibraryProvider): res = False base_dirs = [mdir['path'] for mdir in self._media_dirs] for base_dir in base_dirs: - if path.is_local_path_inside_base_dir(local_path, base_dir): + if path.is_path_inside_base_dir(local_path, base_dir): res = True if not res: logger.warn('%s not inside any base_dir', local_path) diff --git a/mopidy/internal/path.py b/mopidy/internal/path.py index 7573dfd2..f56520f0 100644 --- a/mopidy/internal/path.py +++ b/mopidy/internal/path.py @@ -196,22 +196,22 @@ def find_mtimes(root, follow=False): return mtimes, errors -def is_local_path_inside_base_dir(local_path, base_path): - if local_path.endswith(os.sep): - raise ValueError('Local path %s cannot end with a path separator' - % local_path) +def is_path_inside_base_dir(path, base_path): + if path.endswith(os.sep): + raise ValueError('Path %s cannot end with a path separator' + % path) # Expand symlinks real_base_path = os.path.realpath(base_path) - real_local_path = os.path.realpath(local_path) + real_path = os.path.realpath(path) - if os.path.isfile(local_path): + if os.path.isfile(path): # Use dir of file for prefix comparision, so we don't accept # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a # common prefix, /tmp/foo, which matches the base path, /tmp/foo. - real_local_path = os.path.dirname(real_local_path) + real_path = os.path.dirname(real_path) # Check if dir of file is the base path or a subdir - common_prefix = os.path.commonprefix([real_base_path, real_local_path]) + common_prefix = os.path.commonprefix([real_base_path, real_path]) return common_prefix == real_base_path From ff14909fab579098f6f7acbb97ef99b0fcb3bc06 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Wed, 8 Jul 2015 00:25:48 +0200 Subject: [PATCH 256/318] file-browser: decode Ref.track#name abd Ref.directory#name --- mopidy/files/library.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/files/library.py b/mopidy/files/library.py index 42465fab..92762f03 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -61,6 +61,7 @@ class FilesLibraryProvider(backend.LibraryProvider): if not self._show_dotfiles and dir_entry.startswith(b'.'): continue + dir_entry = dir_entry.decode(sys.getfilesystemencoding(), 'replace') if os.path.isdir(child_path): result.append(models.Ref.directory(name=dir_entry, uri=uri)) elif os.path.isfile(child_path): From 621796d8f81d3f82eda7a81a638c883387ce9cd3 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Wed, 8 Jul 2015 00:42:22 +0200 Subject: [PATCH 257/318] file-browser: lint fixes --- mopidy/files/library.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/files/library.py b/mopidy/files/library.py index 92762f03..0132542e 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -61,7 +61,8 @@ class FilesLibraryProvider(backend.LibraryProvider): if not self._show_dotfiles and dir_entry.startswith(b'.'): continue - dir_entry = dir_entry.decode(sys.getfilesystemencoding(), 'replace') + dir_entry = dir_entry.decode(sys.getfilesystemencoding(), + 'replace') if os.path.isdir(child_path): result.append(models.Ref.directory(name=dir_entry, uri=uri)) elif os.path.isfile(child_path): From 80887319954f0ad507483c59b24171f7b345678a Mon Sep 17 00:00:00 2001 From: rawdlite Date: Wed, 8 Jul 2015 08:19:01 +0200 Subject: [PATCH 258/318] file-browser: Lower severity for logging scanner fail --- mopidy/files/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/files/library.py b/mopidy/files/library.py index 0132542e..e2a9ad81 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -126,7 +126,7 @@ class FilesLibraryProvider(backend.LibraryProvider): result.uri, str(result.playable)) return result.playable except exceptions.ScannerError as e: - logger.warning('Problem scanning %s: %s', uri, e) + logger.debug('Problem scanning %s: %s', uri, e) return False def _is_in_basedir(self, local_path): From 3b1a16dcce127dfad80604e2aee6db7f6bca3c42 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Wed, 8 Jul 2015 08:24:49 +0200 Subject: [PATCH 259/318] file-browser: Changed Message for logging scanner fail --- mopidy/files/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/files/library.py b/mopidy/files/library.py index e2a9ad81..b030b5be 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -126,7 +126,7 @@ class FilesLibraryProvider(backend.LibraryProvider): result.uri, str(result.playable)) return result.playable except exceptions.ScannerError as e: - logger.debug('Problem scanning %s: %s', uri, e) + logger.debug('Could not scan %s: %s', uri, e) return False def _is_in_basedir(self, local_path): From 9da571d2721e47990456f1a699bbc178c2575052 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 8 Jul 2015 12:59:13 +0200 Subject: [PATCH 260/318] mpd: Tweak docstring, add PR#1213 to changelog --- docs/changelog.rst | 4 ++++ mopidy/mpd/translator.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 42298600..62810808 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -50,6 +50,10 @@ MPD frontend - The MPD command ``count`` now ignores tracks with no length, which would previously cause a :exc:`TypeError`. (PR: :issue:`1192`) +- Concatenate multiple artists, composers and performers using the "A;B" format + instead of "A, B". This is a part of updating our protocol implementation to + match MPD 0.19. (PR: :issue:`1213`) + Utils ----- diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 2cd9e3ad..025ccdc9 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -93,7 +93,7 @@ def track_to_mpd_format(track, position=None, stream_title=None): def concat_multi_values(models, attribute): """ - Format mopidy model values for output to MPD client. + Format Mopidy model values for output to MPD client. :param models: the models :type models: array of :class:`mopidy.models.Artist`, From 68531ae5ed19eb942cd91239c49622707eeb5cc6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 8 Jul 2015 13:05:31 +0200 Subject: [PATCH 261/318] docs: Fix typo --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 62810808..be6f030c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,7 +21,7 @@ Core API - Updated core controllers to handle backend exceptions in all calls that rely on multiple backends. (Issue: :issue:`667`) -- Update core methods to do strict input checking. (Fixes: :issue:`#700`) +- Update core methods to do strict input checking. (Fixes: :issue:`700`) - Add ``tlid`` alternatives to methods that take ``tl_track`` and also add ``get_{eot,next,previous}_tlid`` methods as light weight alternatives to the From 25247fc296fee3fb741501ea10ba7b0af759a5ee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 8 Jul 2015 13:24:03 +0200 Subject: [PATCH 262/318] m3u: Fix use of logger.warn() function alias --- mopidy/m3u/playlists.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index 25be5859..cfc2b746 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -51,10 +51,11 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): if os.path.exists(path): os.remove(path) else: - logger.warn('Trying to delete missing playlist file %s', path) + logger.warning( + 'Trying to delete missing playlist file %s', path) del self._playlists[uri] else: - logger.warn('Trying to delete unknown playlist %s', uri) + logger.warning('Trying to delete unknown playlist %s', uri) def lookup(self, uri): return self._playlists.get(uri) From 446a3082002925d7f2c23c78702685faf42a51c8 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Wed, 8 Jul 2015 22:10:36 +0200 Subject: [PATCH 263/318] file-browser: Changed as discussed in PR 1207 --- mopidy/files/library.py | 46 ++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/mopidy/files/library.py b/mopidy/files/library.py index b030b5be..2a347f4b 100644 --- a/mopidy/files/library.py +++ b/mopidy/files/library.py @@ -11,7 +11,7 @@ from mopidy.audio import scan, utils from mopidy.internal import path logger = logging.getLogger(__name__) - +FS_ENCODING = sys.getfilesystemencoding() class FilesLibraryProvider(backend.LibraryProvider): """Library for browsing local files.""" @@ -43,11 +43,17 @@ class FilesLibraryProvider(backend.LibraryProvider): local_path = path.uri_to_path(uri) if local_path == 'root': return list(self._get_media_dirs_refs()) + if not self._is_in_basedir(os.path.realpath(local_path)): + logger.warning( + 'Rejected attempt to browse path (%s) outside dirs defined ' + 'in files/media_dirs config.', + local_path.decode(FS_ENCODING, 'replace')) + return [] for dir_entry in os.listdir(local_path): child_path = os.path.join(local_path, dir_entry) uri = path.path_to_uri(child_path) - printable_path = child_path.decode(sys.getfilesystemencoding(), - 'ignore') + printable_path = child_path.decode(FS_ENCODING, + 'replace') if os.path.islink(child_path) and not self._follow_symlinks: logger.debug('Ignoring symlink: %s', printable_path) @@ -61,7 +67,7 @@ class FilesLibraryProvider(backend.LibraryProvider): if not self._show_dotfiles and dir_entry.startswith(b'.'): continue - dir_entry = dir_entry.decode(sys.getfilesystemencoding(), + dir_entry = dir_entry.decode(FS_ENCODING, 'replace') if os.path.isdir(child_path): result.append(models.Ref.directory(name=dir_entry, uri=uri)) @@ -75,22 +81,22 @@ class FilesLibraryProvider(backend.LibraryProvider): return result def lookup(self, uri): - logger.debug('looking up uri = %s', uri) + logger.debug('Looking up file URI: %s', uri) local_path = path.uri_to_path(uri) if not self._is_in_basedir(local_path): - logger.warn('Ignoring URI outside base dir: %s', local_path) + logger.warning('Ignoring URI outside base dir: %s', local_path) return [] try: result = self._scanner.scan(uri) track = utils.convert_tags_to_track(result.tags).copy( uri=uri, length=result.duration) except exceptions.ScannerError as e: - logger.warning('Problem looking up %s: %s', uri, e) + logger.warning('Failed looking up %s: %s', uri, e) track = models.Track(uri=uri) if not track.name: filename = os.path.basename(local_path) name = urllib2.unquote(filename).decode( - sys.getfilesystemencoding(), 'ignore') + FS_ENCODING, 'replace') track = track.copy(name=name) return [track] @@ -99,9 +105,10 @@ class FilesLibraryProvider(backend.LibraryProvider): media_dir = {} media_dir_split = entry.split('|', 1) local_path = path.expand_path( - media_dir_split[0].encode(sys.getfilesystemencoding())) + media_dir_split[0].encode(FS_ENCODING)) if not local_path: - logger.warn('Could not expand path %s', media_dir_split[0]) + logger.warn('Failed expanding path (%s) from files/media_dirs config value.', + media_dir_split[0]) continue elif not os.path.isdir(local_path): logger.warn('%s is not a directory', local_path) @@ -110,6 +117,7 @@ class FilesLibraryProvider(backend.LibraryProvider): if len(media_dir_split) == 2: media_dir['name'] = media_dir_split[1] else: + # TODO Mpd client should accept / in dir name media_dir['name'] = media_dir_split[0].replace(os.sep, '+') yield media_dir @@ -122,19 +130,15 @@ class FilesLibraryProvider(backend.LibraryProvider): def _is_audiofile(self, uri): try: result = self._scanner.scan(uri) - logger.debug('got scan result playable: %s for %s', - result.uri, str(result.playable)) + logger.debug( + 'Scan indicates that file %s is %s.', + result.uri, result.playable and 'playable' or 'unplayable') return result.playable except exceptions.ScannerError as e: - logger.debug('Could not scan %s: %s', uri, e) + logger.debug("Failed scanning %s: %s", uri, e) return False def _is_in_basedir(self, local_path): - res = False - base_dirs = [mdir['path'] for mdir in self._media_dirs] - for base_dir in base_dirs: - if path.is_path_inside_base_dir(local_path, base_dir): - res = True - if not res: - logger.warn('%s not inside any base_dir', local_path) - return res + return any( + path.is_path_inside_base_dir(local_path, media_dir['path']) + for media_dir in self._media_dirs) From d0c255f59489dcf329953e3856bed49b79895671 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Wed, 8 Jul 2015 22:06:19 +0100 Subject: [PATCH 264/318] mpd:Fix swapped Name and Title fields for streams Fixes issue#1212 When stream_title is set: use stream_title for mpd's Title field, and use track.name (if set) for mpd's Name field When stream_title is not set: use track.name for mpd's Title field. Do not output Name field. --- mopidy/mpd/translator.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 025ccdc9..04abc682 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -38,12 +38,15 @@ def track_to_mpd_format(track, position=None, stream_title=None): # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 ('Time', track.length and (track.length // 1000) or 0), ('Artist', concat_multi_values(track.artists, 'name')), - ('Title', track.name or ''), ('Album', track.album and track.album.name or ''), ] - if stream_title: - result.append(('Name', stream_title)) + if stream_title is not None: + result.append(('Title', stream_title)) + if track.name: + result.append(('Name', track.name)) + else: + result.append(('Title', track.name or '')) if track.date: result.append(('Date', track.date)) From 07d4f6ddf2255432ae54b6df51ef52b62a0e32f4 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Wed, 8 Jul 2015 23:43:40 +0200 Subject: [PATCH 265/318] Rename Mopidy-Files to Mopidy-File --- docs/ext/backends.rst | 6 +++--- docs/ext/{files.rst => file.rst} | 20 ++++++++++---------- docs/index.rst | 2 +- mopidy/{files => file}/__init__.py | 4 ++-- mopidy/{files => file}/backend.py | 2 +- mopidy/{files => file}/ext.conf | 2 +- mopidy/{files => file}/library.py | 18 ++++++++++-------- setup.py | 2 +- 8 files changed, 29 insertions(+), 27 deletions(-) rename docs/ext/{files.rst => file.rst} (72%) rename mopidy/{files => file}/__init__.py (94%) rename mopidy/{files => file}/backend.py (94%) rename mopidy/{files => file}/ext.conf (94%) rename mopidy/{files => file}/library.py (90%) diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 19f59806..5f578e6f 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -65,10 +65,10 @@ https://github.com/tkem/mopidy-dleyna Provides a backend for playing music from Digital Media Servers using the `dLeyna `_ D-Bus interface. -Mopidy-Files -============ +Mopidy-File +=========== -Bundled with Mopidy. See :ref:`ext-files`. +Bundled with Mopidy. See :ref:`ext-file`. Mopidy-Grooveshark ================== diff --git a/docs/ext/files.rst b/docs/ext/file.rst similarity index 72% rename from docs/ext/files.rst rename to docs/ext/file.rst index 42120807..d31f53fd 100644 --- a/docs/ext/files.rst +++ b/docs/ext/file.rst @@ -1,10 +1,10 @@ -.. _ext-files: +.. _ext-file: ************ -Mopidy-Files +Mopidy-File ************ -Mopidy-Files is an extension for playing music from your local music archive. +Mopidy-File is an extension for playing music from your local music archive. It is bundled with Mopidy and enabled by default. It allows you to browse through your local file system. Only files that are considered playable will be shown. @@ -17,30 +17,30 @@ Configuration See :ref:`config` for general help on configuring Mopidy. -.. literalinclude:: ../../mopidy/files/ext.conf +.. literalinclude:: ../../mopidy/file/ext.conf :language: ini -.. confval:: files/enabled +.. confval:: file/enabled - If the files extension should be enabled or not. + If the file extension should be enabled or not. -.. confval:: files/media_dirs +.. confval:: file/media_dirs A list of directories to be browsable. Optionally the path can be followed by ``|`` and a name that will be shown for that path. -.. confval:: files/show_dotfiles +.. confval:: file/show_dotfiles Whether to show hidden files and directories that start with a dot. Default is false. -.. confval:: files/follow_symlinks +.. confval:: file/follow_symlinks Whether to follow symbolic links found in :confval:`files/media_dir`. Directories and files that are outside the configured directories will not be shown. Default is false. -.. confval:: files/metadata_timeout +.. confval:: file/metadata_timeout Number of milliseconds before giving up scanning a file and moving on to the next file. Reducing the value might speed up the directory listing, diff --git a/docs/index.rst b/docs/index.rst index 8d621d26..9085024a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,7 +96,7 @@ Extensions :maxdepth: 2 ext/local - ext/files + ext/file ext/m3u ext/stream ext/http diff --git a/mopidy/files/__init__.py b/mopidy/file/__init__.py similarity index 94% rename from mopidy/files/__init__.py rename to mopidy/file/__init__.py index d547b256..089cf6e6 100644 --- a/mopidy/files/__init__.py +++ b/mopidy/file/__init__.py @@ -11,8 +11,8 @@ logger = logging.getLogger(__name__) class Extension(ext.Extension): - dist_name = 'Mopidy-Files' - ext_name = 'files' + dist_name = 'Mopidy-File' + ext_name = 'file' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/files/backend.py b/mopidy/file/backend.py similarity index 94% rename from mopidy/files/backend.py rename to mopidy/file/backend.py index 2394881c..74b029e5 100644 --- a/mopidy/files/backend.py +++ b/mopidy/file/backend.py @@ -5,7 +5,7 @@ import logging import pykka from mopidy import backend -from mopidy.files import library +from mopidy.file import library logger = logging.getLogger(__name__) diff --git a/mopidy/files/ext.conf b/mopidy/file/ext.conf similarity index 94% rename from mopidy/files/ext.conf rename to mopidy/file/ext.conf index afdd1183..486619a1 100644 --- a/mopidy/files/ext.conf +++ b/mopidy/file/ext.conf @@ -1,4 +1,4 @@ -[files] +[file] enabled = true media_dirs = $XDG_MUSIC_DIR|Music diff --git a/mopidy/files/library.py b/mopidy/file/library.py similarity index 90% rename from mopidy/files/library.py rename to mopidy/file/library.py index 2a347f4b..d638d0f0 100644 --- a/mopidy/files/library.py +++ b/mopidy/file/library.py @@ -13,6 +13,7 @@ from mopidy.internal import path logger = logging.getLogger(__name__) FS_ENCODING = sys.getfilesystemencoding() + class FilesLibraryProvider(backend.LibraryProvider): """Library for browsing local files.""" # TODO: get_images that can pull from metadata and/or .folder.png etc? @@ -32,10 +33,10 @@ class FilesLibraryProvider(backend.LibraryProvider): def __init__(self, backend, config): super(FilesLibraryProvider, self).__init__(backend) self._media_dirs = list(self._get_media_dirs(config)) - self._follow_symlinks = config['files']['follow_symlinks'] - self._show_dotfiles = config['files']['show_dotfiles'] + self._follow_symlinks = config['file']['follow_symlinks'] + self._show_dotfiles = config['file']['show_dotfiles'] self._scanner = scan.Scanner( - timeout=config['files']['metadata_timeout']) + timeout=config['file']['metadata_timeout']) def browse(self, uri): logger.debug('Browsing files at: %s', uri) @@ -46,7 +47,7 @@ class FilesLibraryProvider(backend.LibraryProvider): if not self._is_in_basedir(os.path.realpath(local_path)): logger.warning( 'Rejected attempt to browse path (%s) outside dirs defined ' - 'in files/media_dirs config.', + 'in file/media_dirs config.', local_path.decode(FS_ENCODING, 'replace')) return [] for dir_entry in os.listdir(local_path): @@ -101,17 +102,18 @@ class FilesLibraryProvider(backend.LibraryProvider): return [track] def _get_media_dirs(self, config): - for entry in config['files']['media_dirs']: + for entry in config['file']['media_dirs']: media_dir = {} media_dir_split = entry.split('|', 1) local_path = path.expand_path( media_dir_split[0].encode(FS_ENCODING)) if not local_path: - logger.warn('Failed expanding path (%s) from files/media_dirs config value.', - media_dir_split[0]) + logger.warning('Failed expanding path (%s) from file/media_dirs' + 'config value.', + media_dir_split[0]) continue elif not os.path.isdir(local_path): - logger.warn('%s is not a directory', local_path) + logger.warning('%s is not a directory', local_path) continue media_dir['path'] = local_path if len(media_dir_split) == 2: diff --git a/setup.py b/setup.py index ec302548..ca121f74 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ setup( 'mopidy.ext': [ 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', - 'files = mopidy.files:Extension', + 'file = mopidy.file:Extension', 'm3u = mopidy.m3u:Extension', 'mpd = mopidy.mpd:Extension', 'softwaremixer = mopidy.softwaremixer:Extension', From 4e0c114ce3b5d9e985b188d014a7ffb10380d952 Mon Sep 17 00:00:00 2001 From: rawdlite Date: Thu, 9 Jul 2015 07:10:09 +0200 Subject: [PATCH 266/318] file-browser: lint fixed --- mopidy/file/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/file/library.py b/mopidy/file/library.py index d638d0f0..f9c4ad97 100644 --- a/mopidy/file/library.py +++ b/mopidy/file/library.py @@ -108,8 +108,8 @@ class FilesLibraryProvider(backend.LibraryProvider): local_path = path.expand_path( media_dir_split[0].encode(FS_ENCODING)) if not local_path: - logger.warning('Failed expanding path (%s) from file/media_dirs' - 'config value.', + logger.warning('Failed expanding path (%s) from' + 'file/media_dirs config value.', media_dir_split[0]) continue elif not os.path.isdir(local_path): From b6f4ba9c1113a1209163f9c092c32349be7d616c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jul 2015 13:12:55 +0200 Subject: [PATCH 267/318] Update .mailmap --- .mailmap | 1 + 1 file changed, 1 insertion(+) diff --git a/.mailmap b/.mailmap index 54e01b7d..a198bebd 100644 --- a/.mailmap +++ b/.mailmap @@ -24,3 +24,4 @@ Christopher Schirner John Cass Ronald Zielaznicki +Tom Roth From f3ec7e72029a1601f3a6943be64b37db61e518d3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jul 2015 13:14:01 +0200 Subject: [PATCH 268/318] docs: Update list of authors --- AUTHORS | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AUTHORS b/AUTHORS index 91b71008..20e0aed6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -53,3 +53,9 @@ - Laura Barber - Jakab Kristóf - Ronald Zielaznicki +- Wojciech Wnętrzak +- Camilo Nova +- Dražen Lučanin +- Naglis Jonaitis +- Tom Roth +- Mark Greenwood From 61ee92e2210e39d3f861aa8b6ae5d956eb5d11ba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jul 2015 13:18:52 +0200 Subject: [PATCH 269/318] file: Minor style tweaks --- mopidy/file/__init__.py | 4 ++-- mopidy/file/backend.py | 7 +++---- mopidy/file/library.py | 31 ++++++++++++++++++++----------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/mopidy/file/__init__.py b/mopidy/file/__init__.py index 089cf6e6..ea4dea12 100644 --- a/mopidy/file/__init__.py +++ b/mopidy/file/__init__.py @@ -28,5 +28,5 @@ class Extension(ext.Extension): return schema def setup(self, registry): - from .backend import FilesBackend - registry.add('backend', FilesBackend) + from .backend import FileBackend + registry.add('backend', FileBackend) diff --git a/mopidy/file/backend.py b/mopidy/file/backend.py index 74b029e5..bc5af48b 100644 --- a/mopidy/file/backend.py +++ b/mopidy/file/backend.py @@ -11,12 +11,11 @@ from mopidy.file import library logger = logging.getLogger(__name__) -class FilesBackend(pykka.ThreadingActor, backend.Backend): +class FileBackend(pykka.ThreadingActor, backend.Backend): uri_schemes = ['file'] def __init__(self, config, audio): - super(FilesBackend, self).__init__() - self.library = library.FilesLibraryProvider(backend=self, - config=config) + super(FileBackend, self).__init__() + self.library = library.FileLibraryProvider(backend=self, config=config) self.playback = backend.PlaybackProvider(audio=audio, backend=self) self.playlists = None diff --git a/mopidy/file/library.py b/mopidy/file/library.py index f9c4ad97..c20b92b6 100644 --- a/mopidy/file/library.py +++ b/mopidy/file/library.py @@ -10,12 +10,14 @@ from mopidy import backend, exceptions, models from mopidy.audio import scan, utils from mopidy.internal import path + logger = logging.getLogger(__name__) FS_ENCODING = sys.getfilesystemencoding() -class FilesLibraryProvider(backend.LibraryProvider): +class FileLibraryProvider(backend.LibraryProvider): """Library for browsing local files.""" + # TODO: get_images that can pull from metadata and/or .folder.png etc? # TODO: handle playlists? @@ -24,14 +26,13 @@ class FilesLibraryProvider(backend.LibraryProvider): if not self._media_dirs: return None elif len(self._media_dirs) == 1: - local_path = self._media_dirs[0]['path'] - uri = path.path_to_uri(local_path) + uri = path.path_to_uri(self._media_dirs[0]['path']) else: uri = 'file:root' return models.Ref.directory(name='Files', uri=uri) def __init__(self, backend, config): - super(FilesLibraryProvider, self).__init__(backend) + super(FileLibraryProvider, self).__init__(backend) self._media_dirs = list(self._get_media_dirs(config)) self._follow_symlinks = config['file']['follow_symlinks'] self._show_dotfiles = config['file']['show_dotfiles'] @@ -42,19 +43,21 @@ class FilesLibraryProvider(backend.LibraryProvider): logger.debug('Browsing files at: %s', uri) result = [] local_path = path.uri_to_path(uri) + if local_path == 'root': return list(self._get_media_dirs_refs()) + if not self._is_in_basedir(os.path.realpath(local_path)): logger.warning( 'Rejected attempt to browse path (%s) outside dirs defined ' 'in file/media_dirs config.', local_path.decode(FS_ENCODING, 'replace')) return [] + for dir_entry in os.listdir(local_path): child_path = os.path.join(local_path, dir_entry) uri = path.path_to_uri(child_path) - printable_path = child_path.decode(FS_ENCODING, - 'replace') + printable_path = child_path.decode(FS_ENCODING, 'replace') if os.path.islink(child_path) and not self._follow_symlinks: logger.debug('Ignoring symlink: %s', printable_path) @@ -68,13 +71,12 @@ class FilesLibraryProvider(backend.LibraryProvider): if not self._show_dotfiles and dir_entry.startswith(b'.'): continue - dir_entry = dir_entry.decode(FS_ENCODING, - 'replace') + name = dir_entry.decode(FS_ENCODING, 'replace') if os.path.isdir(child_path): - result.append(models.Ref.directory(name=dir_entry, uri=uri)) + result.append(models.Ref.directory(name=name, uri=uri)) elif os.path.isfile(child_path): if self._is_audiofile(uri): - result.append(models.Ref.track(name=dir_entry, uri=uri)) + result.append(models.Ref.track(name=name, uri=uri)) else: logger.debug('Ignoring non-audiofile: %s', printable_path) @@ -84,9 +86,11 @@ class FilesLibraryProvider(backend.LibraryProvider): def lookup(self, uri): logger.debug('Looking up file URI: %s', uri) local_path = path.uri_to_path(uri) + if not self._is_in_basedir(local_path): logger.warning('Ignoring URI outside base dir: %s', local_path) return [] + try: result = self._scanner.scan(uri) track = utils.convert_tags_to_track(result.tags).copy( @@ -94,11 +98,13 @@ class FilesLibraryProvider(backend.LibraryProvider): except exceptions.ScannerError as e: logger.warning('Failed looking up %s: %s', uri, e) track = models.Track(uri=uri) + if not track.name: filename = os.path.basename(local_path) name = urllib2.unquote(filename).decode( FS_ENCODING, 'replace') track = track.copy(name=name) + return [track] def _get_media_dirs(self, config): @@ -107,6 +113,7 @@ class FilesLibraryProvider(backend.LibraryProvider): media_dir_split = entry.split('|', 1) local_path = path.expand_path( media_dir_split[0].encode(FS_ENCODING)) + if not local_path: logger.warning('Failed expanding path (%s) from' 'file/media_dirs config value.', @@ -115,12 +122,14 @@ class FilesLibraryProvider(backend.LibraryProvider): elif not os.path.isdir(local_path): logger.warning('%s is not a directory', local_path) continue + media_dir['path'] = local_path if len(media_dir_split) == 2: media_dir['name'] = media_dir_split[1] else: # TODO Mpd client should accept / in dir name media_dir['name'] = media_dir_split[0].replace(os.sep, '+') + yield media_dir def _get_media_dirs_refs(self): @@ -137,7 +146,7 @@ class FilesLibraryProvider(backend.LibraryProvider): result.uri, result.playable and 'playable' or 'unplayable') return result.playable except exceptions.ScannerError as e: - logger.debug("Failed scanning %s: %s", uri, e) + logger.debug('Failed scanning %s: %s', uri, e) return False def _is_in_basedir(self, local_path): From 9035d85a0ef4810ac953599f3b9aa6c2cba2756a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jul 2015 13:22:10 +0200 Subject: [PATCH 270/318] file: Ignore dotfiles before symlinks This reduces the amount of debug logging a lot when browsing a typical Unix home directory. --- mopidy/file/library.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/file/library.py b/mopidy/file/library.py index c20b92b6..1181d7e7 100644 --- a/mopidy/file/library.py +++ b/mopidy/file/library.py @@ -59,6 +59,9 @@ class FileLibraryProvider(backend.LibraryProvider): uri = path.path_to_uri(child_path) printable_path = child_path.decode(FS_ENCODING, 'replace') + if not self._show_dotfiles and dir_entry.startswith(b'.'): + continue + if os.path.islink(child_path) and not self._follow_symlinks: logger.debug('Ignoring symlink: %s', printable_path) continue @@ -68,9 +71,6 @@ class FileLibraryProvider(backend.LibraryProvider): printable_path) continue - if not self._show_dotfiles and dir_entry.startswith(b'.'): - continue - name = dir_entry.decode(FS_ENCODING, 'replace') if os.path.isdir(child_path): result.append(models.Ref.directory(name=name, uri=uri)) From f95cb857f64e50d48262456e9c119ef5c66f5199 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jul 2015 13:38:30 +0200 Subject: [PATCH 271/318] file: Drop double logging of non-audio files --- mopidy/file/library.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/mopidy/file/library.py b/mopidy/file/library.py index 1181d7e7..f04791cd 100644 --- a/mopidy/file/library.py +++ b/mopidy/file/library.py @@ -74,11 +74,8 @@ class FileLibraryProvider(backend.LibraryProvider): name = dir_entry.decode(FS_ENCODING, 'replace') if os.path.isdir(child_path): result.append(models.Ref.directory(name=name, uri=uri)) - elif os.path.isfile(child_path): - if self._is_audiofile(uri): - result.append(models.Ref.track(name=name, uri=uri)) - else: - logger.debug('Ignoring non-audiofile: %s', printable_path) + elif os.path.isfile(child_path) and self._is_audio_file(uri): + result.append(models.Ref.track(name=name, uri=uri)) result.sort(key=operator.attrgetter('name')) return result @@ -138,15 +135,16 @@ class FileLibraryProvider(backend.LibraryProvider): name=media_dir['name'], uri=path.path_to_uri(media_dir['path'])) - def _is_audiofile(self, uri): + def _is_audio_file(self, uri): try: result = self._scanner.scan(uri) - logger.debug( - 'Scan indicates that file %s is %s.', - result.uri, result.playable and 'playable' or 'unplayable') + if result.playable: + logger.debug('Playable file: %s', result.uri) + else: + logger.debug('Unplayable file: %s (not audio)', result.uri) return result.playable except exceptions.ScannerError as e: - logger.debug('Failed scanning %s: %s', uri, e) + logger.debug('Unplayable file: %s (%s)', uri, e) return False def _is_in_basedir(self, local_path): From f3d6309d455e103b3fb6ee5271c2fba2f598f460 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jul 2015 13:49:18 +0200 Subject: [PATCH 272/318] file: Consistently use URI in all log messages It has the benefit of being able to encode any bytes irespective of file system encoding, because of its urlencoding. --- mopidy/file/library.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/mopidy/file/library.py b/mopidy/file/library.py index f04791cd..10586561 100644 --- a/mopidy/file/library.py +++ b/mopidy/file/library.py @@ -50,25 +50,22 @@ class FileLibraryProvider(backend.LibraryProvider): if not self._is_in_basedir(os.path.realpath(local_path)): logger.warning( 'Rejected attempt to browse path (%s) outside dirs defined ' - 'in file/media_dirs config.', - local_path.decode(FS_ENCODING, 'replace')) + 'in file/media_dirs config.', uri) return [] for dir_entry in os.listdir(local_path): child_path = os.path.join(local_path, dir_entry) uri = path.path_to_uri(child_path) - printable_path = child_path.decode(FS_ENCODING, 'replace') if not self._show_dotfiles and dir_entry.startswith(b'.'): continue if os.path.islink(child_path) and not self._follow_symlinks: - logger.debug('Ignoring symlink: %s', printable_path) + logger.debug('Ignoring symlink: %s', uri) continue if not self._is_in_basedir(os.path.realpath(child_path)): - logger.debug('Ignoring symlink to outside base dir: %s', - printable_path) + logger.debug('Ignoring symlink to outside base dir: %s', uri) continue name = dir_entry.decode(FS_ENCODING, 'replace') @@ -98,8 +95,7 @@ class FileLibraryProvider(backend.LibraryProvider): if not track.name: filename = os.path.basename(local_path) - name = urllib2.unquote(filename).decode( - FS_ENCODING, 'replace') + name = urllib2.unquote(filename).decode(FS_ENCODING, 'replace') track = track.copy(name=name) return [track] From 5a3dc180fb09de955341385808ab2d541cbabf1c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jul 2015 14:04:21 +0200 Subject: [PATCH 273/318] docs: Add file backend to changelog Fixes #1004 --- docs/changelog.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index be6f030c..dd7170c0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -54,6 +54,30 @@ MPD frontend instead of "A, B". This is a part of updating our protocol implementation to match MPD 0.19. (PR: :issue:`1213`) +File backend +------------ + +The :ref:`Mopidy-File ` backend is new bundled backend. It is similar +to Mopidy-Local since it works with local files, but it differs in a few key +ways: + +- Mopidy-File lets you browse your media files by their file hierarchy. + +- It supports multiple media directories, all exposed under the "Files" + directory when you browse your library with e.g. an MPD client. + +- There is no index of the media files, like the JSON or Sqlite files used by + Mopidy-Local. Thus no need to scan the music collection before starting + Mopidy. Everything is read from the file system when needed and changes to + the file system is thus immediately visible in Mopidy clients. + +- Because there is no index, there is no support for search. + +Our long term plan is to keep this very simple file backend in Mopidy, as it +has a well defined and limited scope, while splitting the more feature rich +Mopidy-Local extension out to an independent project. (Fixes: :issue:`1004`, +PR: :issue:`1207`) + Utils ----- From a99e161aab413a3b7060ce1964c5adf1352baf33 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jul 2015 21:40:04 +0200 Subject: [PATCH 274/318] docs: Fix typos --- docs/changelog.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index dd7170c0..175a3b99 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,16 +57,16 @@ MPD frontend File backend ------------ -The :ref:`Mopidy-File ` backend is new bundled backend. It is similar -to Mopidy-Local since it works with local files, but it differs in a few key -ways: +The :ref:`Mopidy-File ` backend is a new bundled backend. It is +similar to Mopidy-Local since it works with local files, but it differs in a +few key ways: - Mopidy-File lets you browse your media files by their file hierarchy. - It supports multiple media directories, all exposed under the "Files" directory when you browse your library with e.g. an MPD client. -- There is no index of the media files, like the JSON or Sqlite files used by +- There is no index of the media files, like the JSON or SQLite files used by Mopidy-Local. Thus no need to scan the music collection before starting Mopidy. Everything is read from the file system when needed and changes to the file system is thus immediately visible in Mopidy clients. From 5c6ab0846e03d5f53e488ea2bb0fe4b387acc5c2 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Sun, 12 Jul 2015 21:39:27 +0100 Subject: [PATCH 275/318] Fix #1218 Output the last_modified timestamp from mopidy's track model to mpd clients in the same format as mpd uses - yyyy-mm-ddTHH:MM:SS Outputs nothing for Last-Modified if last_modified is None --- mopidy/mpd/translator.py | 6 ++++++ tests/mpd/test_translator.py | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 025ccdc9..4dc5a5b7 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import datetime import re from mopidy.models import TlTrack @@ -86,6 +87,11 @@ def track_to_mpd_format(track, position=None, stream_title=None): if track.disc_no: result.append(('Disc', track.disc_no)) + if track.last_modified is not None: + datestring = datetime.datetime.fromtimestamp( + track.last_modified // 1000).strftime('%Y-%m-%dT%H:%M:%S%Z') + result.append(('Last-Modified', datestring)) + if track.musicbrainz_id is not None: result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) return result diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 99c87dad..812c1510 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -22,7 +22,7 @@ class TrackMpdFormatTest(unittest.TestCase): date='1977-01-01', disc_no=1, comment='a comment', - length=137000, + length=137000 ) def setUp(self): # noqa: N802 @@ -79,6 +79,25 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertNotIn(('Comment', 'a comment'), result) self.assertEqual(len(result), 14) + def test_track_to_mpd_format_with_last_modified(self): + track = self.track.replace(last_modified=995303899000) + result = translator.track_to_mpd_format(track) + self.assertIn(('file', 'a uri'), result) + self.assertIn(('Time', 137), result) + self.assertIn(('Artist', 'an artist'), result) + self.assertIn(('Title', 'a name'), result) + self.assertIn(('Album', 'an album'), result) + self.assertIn(('AlbumArtist', 'an other artist'), result) + self.assertIn(('Composer', 'a composer'), result) + self.assertIn(('Performer', 'a performer'), result) + self.assertIn(('Genre', 'a genre'), result) + self.assertIn(('Track', '7/13'), result) + self.assertIn(('Date', '1977-01-01'), result) + self.assertIn(('Disc', 1), result) + self.assertIn(('Last-Modified', '2001-07-16T18:18:19'), result) + self.assertNotIn(('Comment', 'a comment'), result) + self.assertEqual(len(result), 13) + def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.replace(musicbrainz_id='foo') result = translator.track_to_mpd_format(track) From 6d2ac7a100a0e21fc161ff4c5a4b4de6d9f277dc Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Mon, 13 Jul 2015 06:35:34 +0100 Subject: [PATCH 276/318] Fix #1218 Output a track's Last-Modified stamp in ISO 8601 format, as MPD does. Output nothing if track has no last-modified stamp. The test has to use datetime to work out what the output will look like, because it is local-time zone dependant. --- mopidy/mpd/translator.py | 2 +- tests/mpd/test_translator.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 4dc5a5b7..0e7cb1ff 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -89,7 +89,7 @@ def track_to_mpd_format(track, position=None, stream_title=None): if track.last_modified is not None: datestring = datetime.datetime.fromtimestamp( - track.last_modified // 1000).strftime('%Y-%m-%dT%H:%M:%S%Z') + track.last_modified // 1000).isoformat() result.append(('Last-Modified', datestring)) if track.musicbrainz_id is not None: diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 812c1510..39ce34e6 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import datetime import unittest from mopidy.internal import path @@ -81,6 +82,12 @@ class TrackMpdFormatTest(unittest.TestCase): def test_track_to_mpd_format_with_last_modified(self): track = self.track.replace(last_modified=995303899000) + # Due to this being local time-zone dependant, we have + # to calculate what the Last-Modified output will look like + # on the machine where the tests are being run. So this only tests + # that the output is actually there. + datestring = datetime.datetime.fromtimestamp( + track.last_modified // 1000).isoformat() result = translator.track_to_mpd_format(track) self.assertIn(('file', 'a uri'), result) self.assertIn(('Time', 137), result) @@ -94,7 +101,7 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertIn(('Track', '7/13'), result) self.assertIn(('Date', '1977-01-01'), result) self.assertIn(('Disc', 1), result) - self.assertIn(('Last-Modified', '2001-07-16T18:18:19'), result) + self.assertIn(('Last-Modified', datestring), result) self.assertNotIn(('Comment', 'a comment'), result) self.assertEqual(len(result), 13) From 38ca65a0b62d5f92a00bc1af09114dcf991e6fcd Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Mon, 13 Jul 2015 06:43:46 +0100 Subject: [PATCH 277/318] Fix #1218 - Last-Modified timestamp for tracks Don't know why the Travis build failed, it's passing for me --- tests/mpd/test_translator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 39ce34e6..3488442e 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -23,7 +23,7 @@ class TrackMpdFormatTest(unittest.TestCase): date='1977-01-01', disc_no=1, comment='a comment', - length=137000 + length=137000, ) def setUp(self): # noqa: N802 From c69c68648b7eafde8344e2d3d28c5649966ecd29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 16 Jul 2015 21:18:12 +0200 Subject: [PATCH 278/318] core: Add a [core] config section I deemed it better to make core an extension (that cannot be disabled) rather than add a special case throughout the config handling to make it possible to have config section that doesn't belong to an extension. This change is needed for #997. Until #997 is completed, Mopidy will complain that core has now config schema (because it is empty) and claim that core is disabled. This of course has no practical effects. --- mopidy/core/__init__.py | 1 + mopidy/core/ext.conf | 1 + mopidy/core/ext.py | 25 +++++++++++++++++++++++++ setup.py | 1 + 4 files changed, 28 insertions(+) create mode 100644 mopidy/core/ext.conf create mode 100644 mopidy/core/ext.py diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 720f9c38..912856d0 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Core +from .ext import Extension from .history import HistoryController from .library import LibraryController from .listener import CoreListener diff --git a/mopidy/core/ext.conf b/mopidy/core/ext.conf new file mode 100644 index 00000000..94b2ad54 --- /dev/null +++ b/mopidy/core/ext.conf @@ -0,0 +1 @@ +[core] diff --git a/mopidy/core/ext.py b/mopidy/core/ext.py new file mode 100644 index 00000000..7a610244 --- /dev/null +++ b/mopidy/core/ext.py @@ -0,0 +1,25 @@ +from __future__ import absolute_import, unicode_literals + +import os + +import mopidy +from mopidy import config, ext + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-Core' + ext_name = 'core' + version = mopidy.__version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + def get_config_schema(self): + schema = super(Extension, self).get_config_schema() + del schema['enabled'] # core cannot be disabled + return schema + + def setup(self, registry): + pass # core has nothing to register diff --git a/setup.py b/setup.py index ca121f74..394431fc 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ setup( 'mopidy = mopidy.__main__:main', ], 'mopidy.ext': [ + 'core = mopidy.core:Extension', 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', 'file = mopidy.file:Extension', From 87bf261345919e90cb88853165fb1556046c80ef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 16 Jul 2015 21:30:22 +0200 Subject: [PATCH 279/318] tests: Fix typo in mock usage The error was made evident by a newer mock version that no longer swallowed the wrong assert as regular use of a spec-less mock. --- tests/mpd/protocol/test_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpd/protocol/test_connection.py b/tests/mpd/protocol/test_connection.py index 9c7edb4b..ae2212f6 100644 --- a/tests/mpd/protocol/test_connection.py +++ b/tests/mpd/protocol/test_connection.py @@ -10,7 +10,7 @@ class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: self.send_request('close') - close_mock.assertEqualResponsecalled_once_with() + close_mock.assert_called_once_with() self.assertEqualResponse('OK') def test_empty_request(self): From 1892e472a0e04097c509737b9f4c53a84b71c0c4 Mon Sep 17 00:00:00 2001 From: Ronald Zielaznicki Date: Fri, 17 Jul 2015 23:06:31 -0400 Subject: [PATCH 280/318] Added config schema --- mopidy/core/ext.conf | 2 ++ mopidy/core/ext.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/core/ext.conf b/mopidy/core/ext.conf index 94b2ad54..f186adf0 100644 --- a/mopidy/core/ext.conf +++ b/mopidy/core/ext.conf @@ -1 +1,3 @@ [core] +enabled = true +max_tracklist_length = 10000 diff --git a/mopidy/core/ext.py b/mopidy/core/ext.py index 7a610244..9b758119 100644 --- a/mopidy/core/ext.py +++ b/mopidy/core/ext.py @@ -18,7 +18,8 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() - del schema['enabled'] # core cannot be disabled + schema['max_tracklist_length'] = config.Integer( + minimum=1, maximum=10000) return schema def setup(self, registry): From 4614f04c8b3d2b0cb61708e9d4a721a2d3b60322 Mon Sep 17 00:00:00 2001 From: Ronald Zielaznicki Date: Sun, 19 Jul 2015 13:31:43 -0400 Subject: [PATCH 281/318] Added in a CoreError with a TracklistFull subclass --- mopidy/exceptions.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index d02a288a..3fb2f1c2 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -19,6 +19,13 @@ class MopidyException(Exception): class BackendError(MopidyException): pass + + +class CoreError(MopidyException): + + def __init(self, message, errno=None): + super(CoreError, self).__init(message, errno) + self.errno = errno class ExtensionError(MopidyException): @@ -43,6 +50,13 @@ class MixerError(MopidyException): class ScannerError(MopidyException): pass + +class TracklistFull(CoreError): + + def __init(self, message, errno=None): + super(TracklistFull, self).__init(message, errno) + self.errno = errno + class AudioException(MopidyException): pass From f6f490efc5662333fc8727115d5568dba98a8488 Mon Sep 17 00:00:00 2001 From: Ronald Zielaznicki Date: Sun, 19 Jul 2015 20:23:48 -0400 Subject: [PATCH 282/318] Add a max playlist limit to the tracklist. --- mopidy/core/tracklist.py | 8 ++++++++ mopidy/exceptions.py | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 028e7c02..0b3e55d2 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import logging import random +from mopidy import exceptions from mopidy.core import listener from mopidy.internal import deprecation, validation from mopidy.models import TlTrack, Track @@ -433,6 +434,13 @@ class TracklistController(object): tl_tracks = [] for track in tracks: + if(self.get_length() >= + self.core._config['core']['max_tracklist_length']): + + raise exceptions.TracklistFull( + 'TracklistFull: Tried to add ' + + 'too many uris to the tracklist.') + break tl_track = TlTrack(self._next_tlid, track) self._next_tlid += 1 if at_position is not None: diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 3fb2f1c2..3d25471f 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -19,10 +19,10 @@ class MopidyException(Exception): class BackendError(MopidyException): pass - + class CoreError(MopidyException): - + def __init(self, message, errno=None): super(CoreError, self).__init(message, errno) self.errno = errno @@ -50,9 +50,9 @@ class MixerError(MopidyException): class ScannerError(MopidyException): pass - + class TracklistFull(CoreError): - + def __init(self, message, errno=None): super(TracklistFull, self).__init(message, errno) self.errno = errno From 82ed6607772b50df4033b712038196766efe391c Mon Sep 17 00:00:00 2001 From: Ronald Zielaznicki Date: Sun, 19 Jul 2015 23:05:39 -0400 Subject: [PATCH 283/318] Add core config values to relevent test cases. --- tests/core/test_events.py | 9 +++++++- tests/core/test_playback.py | 41 +++++++++++++++++++++++++++++----- tests/core/test_tracklist.py | 18 ++++++++++++--- tests/local/test_playback.py | 5 ++++- tests/local/test_tracklist.py | 5 ++++- tests/mpd/protocol/__init__.py | 7 +++++- tests/mpd/test_status.py | 10 ++++++++- 7 files changed, 82 insertions(+), 13 deletions(-) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 7c8eba1d..9a439084 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -17,12 +17,19 @@ from tests import dummy_backend class BackendEventsTest(unittest.TestCase): def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.backend = dummy_backend.create_proxy() self.backend.library.dummy_library = [ Track(uri='dummy:a'), Track(uri='dummy:b')] with deprecation.ignore(): - self.core = core.Core.start(backends=[self.backend]).proxy() + self.core = core.Core.start( + config, backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 76054684..c4ba01a6 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -18,6 +18,12 @@ from tests import dummy_audio as audio class CorePlaybackTest(unittest.TestCase): def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.playback1 = mock.Mock(spec=backend.PlaybackProvider) @@ -46,7 +52,7 @@ class CorePlaybackTest(unittest.TestCase): self.uris = [ 'dummy1:a', 'dummy2:a', 'dummy3:a', 'dummy1:b', 'dummy1:c'] - self.core = core.Core(mixer=None, backends=[ + self.core = core.Core(config, mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) def lookup(uris): @@ -614,9 +620,16 @@ class TestBackend(pykka.ThreadingActor, backend.Backend): class TestStream(unittest.TestCase): def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.audio = audio.DummyAudio.start().proxy() self.backend = TestBackend.start(config={}, audio=self.audio).proxy() - self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.core = core.Core( + config, audio=self.audio, backends=[self.backend]) self.playback = self.core.playback self.tracks = [Track(uri='dummy:a', length=1234), @@ -698,6 +711,12 @@ class TestStream(unittest.TestCase): class CorePlaybackWithOldBackendTest(unittest.TestCase): def test_type_error_from_old_backend_does_not_crash_core(self): + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + b = mock.Mock() b.uri_schemes.get.return_value = ['dummy1'] b.playback = mock.Mock(spec=backend.PlaybackProvider) @@ -705,7 +724,7 @@ class CorePlaybackWithOldBackendTest(unittest.TestCase): b.library.lookup.return_value.get.return_value = [ Track(uri='dummy1:a', length=40000)] - c = core.Core(mixer=None, backends=[b]) + c = core.Core(config, mixer=None, backends=[b]) c.tracklist.add(uris=['dummy1:a']) c.playback.play() # No TypeError == test passed. b.playback.play.assert_called_once_with() @@ -714,9 +733,15 @@ class CorePlaybackWithOldBackendTest(unittest.TestCase): class TestPlay(unittest.TestCase): def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.backend = mock.Mock() self.backend.uri_schemes.get.return_value = ['dummy'] - self.core = core.Core(backends=[self.backend]) + self.core = core.Core(config, backends=[self.backend]) self.tracks = [Track(uri='dummy:a', length=1234), Track(uri='dummy:b', length=1234)] @@ -732,6 +757,12 @@ class TestPlay(unittest.TestCase): class Bug1177RegressionTest(unittest.TestCase): def test(self): + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + b = mock.Mock() b.uri_schemes.get.return_value = ['dummy'] b.playback = mock.Mock(spec=backend.PlaybackProvider) @@ -741,7 +772,7 @@ class Bug1177RegressionTest(unittest.TestCase): track1 = Track(uri='dummy:a', length=40000) track2 = Track(uri='dummy:b', length=40000) - c = core.Core(mixer=None, backends=[b]) + c = core.Core(config, mixer=None, backends=[b]) c.tracklist.add([track1, track2]) c.playback.play() diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 83b576ea..24edb2e7 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -11,7 +11,13 @@ from mopidy.models import TlTrack, Track class TracklistTest(unittest.TestCase): - def setUp(self): # noqa: N802 + def setUp(self): # noqa: + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.tracks = [ Track(uri='dummy1:a', name='foo'), Track(uri='dummy1:b', name='foo'), @@ -29,7 +35,7 @@ class TracklistTest(unittest.TestCase): self.library.lookup.side_effect = lookup self.backend.library = self.library - self.core = core.Core(mixer=None, backends=[self.backend]) + self.core = core.Core(config, mixer=None, backends=[self.backend]) self.tl_tracks = self.core.tracklist.add(uris=[ t.uri for t in self.tracks]) @@ -107,6 +113,12 @@ class TracklistTest(unittest.TestCase): class TracklistIndexTest(unittest.TestCase): def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.tracks = [ Track(uri='dummy1:a', name='foo'), Track(uri='dummy1:b', name='foo'), @@ -116,7 +128,7 @@ class TracklistIndexTest(unittest.TestCase): def lookup(uris): return {u: [t for t in self.tracks if t.uri == u] for u in uris} - self.core = core.Core(mixer=None, backends=[]) + self.core = core.Core(config, mixer=None, backends=[]) self.core.library = mock.Mock(spec=core.LibraryController) self.core.library.lookup.side_effect = lookup diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 23e427d9..bab70847 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -22,6 +22,9 @@ from tests.local import generate_song, populate_tracklist class LocalPlaybackProviderTest(unittest.TestCase): config = { + 'core': { + 'max_tracklist_length': 10000, + }, 'local': { 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), @@ -51,7 +54,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) + self.core = core.Core(self.config, backends=[self.backend]) self.playback = self.core.playback self.tracklist = self.core.tracklist diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 63ef8fde..72da3f13 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -17,6 +17,9 @@ from tests.local import generate_song, populate_tracklist class LocalTracklistProviderTest(unittest.TestCase): config = { + 'core': { + 'max_tracklist_length': 10000 + }, 'local': { 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), @@ -35,7 +38,7 @@ class LocalTracklistProviderTest(unittest.TestCase): self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() - self.core = core.Core(mixer=None, backends=[self.backend]) + self.core = core.Core(self.config, mixer=None, backends=[self.backend]) self.controller = self.core.tracklist self.playback = self.core.playback diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index e66bf88a..754b4418 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -31,6 +31,9 @@ class BaseTestCase(unittest.TestCase): def get_config(self): return { + 'core': { + 'max_tracklist_length': 10000 + }, 'mpd': { 'password': None, } @@ -45,7 +48,9 @@ class BaseTestCase(unittest.TestCase): with deprecation.ignore(): self.core = core.Core.start( - mixer=self.mixer, backends=[self.backend]).proxy() + self.get_config(), + mixer=self.mixer, + backends=[self.backend]).proxy() self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index d36ad4dc..76fa9fcb 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -25,12 +25,20 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() with deprecation.ignore(): self.core = core.Core.start( - mixer=self.mixer, backends=[self.backend]).proxy() + config, + mixer=self.mixer, + backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context From 3aacfd7147836ef95133aa88d558a1d69bbcd0cd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 20 Jul 2015 16:45:14 +0200 Subject: [PATCH 284/318] exception: Fix typo in new CoreErrors --- mopidy/exceptions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 3d25471f..4aa66e63 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -23,8 +23,8 @@ class BackendError(MopidyException): class CoreError(MopidyException): - def __init(self, message, errno=None): - super(CoreError, self).__init(message, errno) + def __init__(self, message, errno=None): + super(CoreError, self).__init__(message, errno) self.errno = errno @@ -53,8 +53,8 @@ class ScannerError(MopidyException): class TracklistFull(CoreError): - def __init(self, message, errno=None): - super(TracklistFull, self).__init(message, errno) + def __init__(self, message, errno=None): + super(TracklistFull, self).__init__(message, errno) self.errno = errno From 8bb29cd28ec99f6feb83f3932372aa6817215356 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 20 Jul 2015 16:53:04 +0200 Subject: [PATCH 285/318] core: Update tracklist full error message --- mopidy/core/tracklist.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 0b3e55d2..63a38eae 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -432,15 +432,13 @@ class TracklistController(object): tracks.extend(track_map[uri]) tl_tracks = [] + max_length = self.core._config['core']['max_tracklist_length'] for track in tracks: - if(self.get_length() >= - self.core._config['core']['max_tracklist_length']): - + if self.get_length() >= max_length: raise exceptions.TracklistFull( - 'TracklistFull: Tried to add ' + - 'too many uris to the tracklist.') - break + 'Tracklist may contain at most %d tracks.' % max_length) + tl_track = TlTrack(self._next_tlid, track) self._next_tlid += 1 if at_position is not None: From 2c30934c2eb03a7544b8e63178be084ddeb2241d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 20 Jul 2015 17:01:40 +0200 Subject: [PATCH 286/318] core: Remove core "extension" as it is not needed for config --- mopidy/config/__init__.py | 4 ++++ mopidy/core/__init__.py | 1 - mopidy/core/ext.py | 26 -------------------------- setup.py | 1 - 4 files changed, 4 insertions(+), 28 deletions(-) delete mode 100644 mopidy/core/ext.py diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 3f1f978c..8d3fa376 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -15,6 +15,10 @@ from mopidy.internal import path, versioning logger = logging.getLogger(__name__) +_core_schema = ConfigSchema('core') +# MPD supports at most 10k tracks, some clients segfault when this is exceeded. +_core_schema['max_tracklist_length'] = Integer(minimum=1, maximum=10000) + _logging_schema = ConfigSchema('logging') _logging_schema['color'] = Boolean() _logging_schema['console_format'] = String() diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 912856d0..720f9c38 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Core -from .ext import Extension from .history import HistoryController from .library import LibraryController from .listener import CoreListener diff --git a/mopidy/core/ext.py b/mopidy/core/ext.py deleted file mode 100644 index 9b758119..00000000 --- a/mopidy/core/ext.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import os - -import mopidy -from mopidy import config, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Core' - ext_name = 'core' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['max_tracklist_length'] = config.Integer( - minimum=1, maximum=10000) - return schema - - def setup(self, registry): - pass # core has nothing to register diff --git a/setup.py b/setup.py index 394431fc..ca121f74 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,6 @@ setup( 'mopidy = mopidy.__main__:main', ], 'mopidy.ext': [ - 'core = mopidy.core:Extension', 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', 'file = mopidy.file:Extension', From 8ada2625db8ea01c8be4a87fae8894c1d4d4f33a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 20 Jul 2015 17:07:43 +0200 Subject: [PATCH 287/318] core: Move tracklist setting to default.conf and add changelog --- docs/changelog.rst | 3 +++ mopidy/config/default.conf | 3 +++ mopidy/core/ext.conf | 3 --- 3 files changed, 6 insertions(+), 3 deletions(-) delete mode 100644 mopidy/core/ext.conf diff --git a/docs/changelog.rst b/docs/changelog.rst index 175a3b99..1558042b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -33,6 +33,9 @@ Core API - Update core to handle backend crashes and bad data. (Fixes: :issue:`1161`) +- Add `max_tracklist_length` config and limitation. (Fixes: :issue:`997` + PR: :issue:`1225`) + Models ------ diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 42edbbbd..c214de68 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -1,3 +1,6 @@ +[core] +max_tracklist_length = 10000 + [logging] color = true console_format = %(levelname)-8s %(message)s diff --git a/mopidy/core/ext.conf b/mopidy/core/ext.conf deleted file mode 100644 index f186adf0..00000000 --- a/mopidy/core/ext.conf +++ /dev/null @@ -1,3 +0,0 @@ -[core] -enabled = true -max_tracklist_length = 10000 From d65548de2bb67fbc06f4f9f3c0945d03534e2438 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 20 Jul 2015 18:53:30 +0200 Subject: [PATCH 288/318] docs: Update authors --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 20e0aed6..f996b538 100644 --- a/AUTHORS +++ b/AUTHORS @@ -59,3 +59,4 @@ - Naglis Jonaitis - Tom Roth - Mark Greenwood +- Ronald Zielaznicki From bed8fdd5c5958cf6a9ca62a645906526c6ab71df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 20 Jul 2015 18:59:56 +0200 Subject: [PATCH 289/318] docs: Update authors and .mailmap --- .mailmap | 2 +- AUTHORS | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.mailmap b/.mailmap index a198bebd..718d8f4b 100644 --- a/.mailmap +++ b/.mailmap @@ -23,5 +23,5 @@ Ignasi Fosch Christopher Schirner Laura Barber John Cass -Ronald Zielaznicki +Ronald Zielaznicki Tom Roth diff --git a/AUTHORS b/AUTHORS index f996b538..258967c3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -52,11 +52,10 @@ - John Cass - Laura Barber - Jakab Kristóf -- Ronald Zielaznicki +- Ronald Zielaznicki - Wojciech Wnętrzak - Camilo Nova - Dražen Lučanin - Naglis Jonaitis - Tom Roth - Mark Greenwood -- Ronald Zielaznicki From 1a967d3d2242847eb96b2ccac38b2438a5f6c173 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 20 Jul 2015 19:25:39 +0200 Subject: [PATCH 290/318] mpd: Add tests for stream title handling --- tests/mpd/test_translator.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 99c87dad..270f886c 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -118,6 +118,22 @@ class TrackMpdFormatTest(unittest.TestCase): translated = translator.concat_multi_values(artists, 'musicbrainz_id') self.assertEqual(translated, '') + def test_track_to_mpd_format_with_stream_title(self): + result = translator.track_to_mpd_format(self.track, stream_title='foo') + self.assertIn(('Name', 'a name'), result) + self.assertIn(('Title', 'foo'), result) + + def test_track_to_mpd_format_with_empty_stream_title(self): + result = translator.track_to_mpd_format(self.track, stream_title='') + self.assertIn(('Name', 'a name'), result) + self.assertIn(('Title', ''), result) + + def test_track_to_mpd_format_with_stream_and_no_track_name(self): + track = self.track.replace(name=None) + result = translator.track_to_mpd_format(track, stream_title='foo') + self.assertNotIn(('Name', ''), result) + self.assertIn(('Title', 'foo'), result) + class PlaylistMpdFormatTest(unittest.TestCase): From 0c9542c3d6f05defc76e99c1c6f629ff42e38093 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Wed, 8 Jul 2015 22:06:19 +0100 Subject: [PATCH 291/318] mpd: Fix swapped Name and Title fields for streams Fixes issue#1212 When stream_title is set: use stream_title for mpd's Title field, and use track.name (if set) for mpd's Name field When stream_title is not set: use track.name for mpd's Title field. Do not output Name field. Conflicts: mopidy/mpd/translator.py --- mopidy/mpd/translator.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 8359f86b..b1c139b7 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -38,12 +38,15 @@ def track_to_mpd_format(track, position=None, stream_title=None): # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 ('Time', track.length and (track.length // 1000) or 0), ('Artist', artists_to_mpd_format(track.artists)), - ('Title', track.name or ''), ('Album', track.album and track.album.name or ''), ] - if stream_title: - result.append(('Name', stream_title)) + if stream_title is not None: + result.append(('Title', stream_title)) + if track.name: + result.append(('Name', track.name)) + else: + result.append(('Title', track.name or '')) if track.date: result.append(('Date', track.date)) From ef9a393ba0102fc3cdad9ab2d2f4449578ff94a2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 20 Jul 2015 19:37:25 +0200 Subject: [PATCH 292/318] docs: Changelog for cherrypicked MPD fix --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 818619e4..bf1966ae 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,14 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.8 (unreleased) +=================== + +Bug fix release. + +- Fix reversal of ``Title`` and ``Name`` in MPD protocol (Fixes: :issue:`1212` + PR: :issue:`1219`) + v1.0.7 (2015-06-26) =================== From a1200d38f4ba1b3f2d4570d9fd4d56c1e006eb83 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 16 Jul 2015 21:30:22 +0200 Subject: [PATCH 293/318] tests: Fix typo in mock usage The error was made evident by a newer mock version that no longer swallowed the wrong assert as regular use of a spec-less mock. --- tests/mpd/protocol/test_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpd/protocol/test_connection.py b/tests/mpd/protocol/test_connection.py index da25153d..e25402bd 100644 --- a/tests/mpd/protocol/test_connection.py +++ b/tests/mpd/protocol/test_connection.py @@ -9,7 +9,7 @@ class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: self.send_request('close') - close_mock.assertEqualResponsecalled_once_with() + close_mock.assert_called_once_with() self.assertEqualResponse('OK') def test_empty_request(self): From cd755701411c7009af62848b023962f9b3315318 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 20 Jul 2015 19:49:42 +0200 Subject: [PATCH 294/318] docs: Fix PR number in latest changelog entry --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index bf1966ae..9a811f42 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,7 +10,7 @@ v1.0.8 (unreleased) Bug fix release. - Fix reversal of ``Title`` and ``Name`` in MPD protocol (Fixes: :issue:`1212` - PR: :issue:`1219`) + PR: :issue:`1214`) v1.0.7 (2015-06-26) From c382a58564c36a6f7522e9735b527492c7032004 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 00:34:27 +0200 Subject: [PATCH 295/318] tests: Fix another error in mock usage --- tests/core/test_library.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 51313daa..c49809cf 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -204,7 +204,8 @@ class CoreLibraryTest(unittest.TestCase): self.core.library.refresh() self.library1.refresh.assert_called_once_with(None) - self.library2.refresh.assert_called_twice_with(None) + self.library2.refresh.assert_called_with(None) + self.assertEqual(self.library2.refresh.call_count, 2) def test_find_exact_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') From 01689b7944f4dfe21e243cc313f1492b92e2185f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 09:16:54 +0200 Subject: [PATCH 296/318] docs: Add Mopidy-dam1021 mixer extension Fix #1217 --- docs/ext/mixers.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ext/mixers.rst b/docs/ext/mixers.rst index f934efce..88fd27dd 100644 --- a/docs/ext/mixers.rst +++ b/docs/ext/mixers.rst @@ -29,6 +29,14 @@ Extension for controlling volume using an external Arcam amplifier. Developed and tested with an Arcam AVR-300. +Mopidy-dam1021 +============== + +https://github.com/fortaa/mopidy-dam1021 + +Extension for controlling volume using a dam1021 DAC device. + + Mopidy-NAD ========== From 0832103f797e9ca328fa866c4647a3252c0d36d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 09:26:17 +0200 Subject: [PATCH 297/318] docs: Add Mopidy-Material-Webclient --- docs/ext/web.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 7355dbf9..f6a2110a 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -48,6 +48,22 @@ To install, run:: pip install Mopidy-Local-Images +Mopidy-Material-Webclient +========================= + +https://github.com/matgallacher/mopidy-material-webclient + +A Mopidy web client with an Android Material design feel. + +.. image:: /ext/material_webclient.png + :width: 960 + :height: 520 + +To install, run:: + + pip install Mopidy-Material-Webclient + + Mopidy-Mobile ============= From a23e2bc23c17362c7ddbb5759466686009e5095b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 09:30:01 +0200 Subject: [PATCH 298/318] mpd: Order commands after MPD docs --- mopidy/mpd/protocol/current_playlist.py | 62 ++++++++++++------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index f44abb95..da1a69d1 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -324,6 +324,37 @@ def plchangesposid(context, version): return result +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add( +# 'prio', priority=protocol.UINT, position=protocol.RANGE) +def prio(context, priority, position): + """ + *musicpd.org, current playlist section:* + + ``prio {PRIORITY} {START:END...}`` + + Set the priority of the specified songs. A higher priority means that + it will be played first when "random" mode is enabled. + + A priority is an integer between 0 and 255. The default priority of new + songs is 0. + """ + pass + + +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add('prioid') +def prioid(context, *args): + """ + *musicpd.org, current playlist section:* + + ``prioid {PRIORITY} {ID...}`` + + Same as prio, but address the songs with their id. + """ + pass + + @protocol.commands.add('shuffle', songrange=protocol.RANGE) def shuffle(context, songrange=None): """ @@ -383,37 +414,6 @@ def swapid(context, tlid1, tlid2): swap(context, position1, position2) -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add( -# 'prio', priority=protocol.UINT, position=protocol.RANGE) -def prio(context, priority, position): - """ - *musicpd.org, current playlist section:* - - ``prio {PRIORITY} {START:END...}`` - - Set the priority of the specified songs. A higher priority means that - it will be played first when "random" mode is enabled. - - A priority is an integer between 0 and 255. The default priority of new - songs is 0. - """ - pass - - -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('prioid') -def prioid(context, *args): - """ - *musicpd.org, current playlist section:* - - ``prioid {PRIORITY} {ID...}`` - - Same as prio, but address the songs with their id. - """ - pass - - # TODO: add at least reflection tests before adding NotImplemented version # @protocol.commands.add('addtagid', tlid=protocol.UINT) def addtagid(context, tlid, tag, value): From 7b711e4daceb1f3fd2066afdeeca7d874bd0a354 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 14:01:45 +0200 Subject: [PATCH 299/318] mpd: Add rangeid command skeleton --- docs/changelog.rst | 5 +++++ mopidy/mpd/protocol/current_playlist.py | 16 ++++++++++++++++ tests/mpd/protocol/test_current_playlist.py | 7 +++++++ 3 files changed, 28 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1558042b..eebc3b0c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,6 +57,11 @@ MPD frontend instead of "A, B". This is a part of updating our protocol implementation to match MPD 0.19. (PR: :issue:`1213`) +- Add skeletons of new or otherwise missing MPD commands that return a "not + implemented" error: + + - ``rangeid`` + File backend ------------ diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index da1a69d1..6eeeccf5 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -355,6 +355,22 @@ def prioid(context, *args): pass +@protocol.commands.add('rangeid', tlid=protocol.UINT, songrange=protocol.RANGE) +def rangeid(context, tlid, songrange): + """ + *musicpd.org, current playlist section:* + + ``rangeid {ID} {START:END}`` + + Specifies the portion of the song that shall be played. START and END + are offsets in seconds (fractional seconds allowed); both are optional. + Omitting both (i.e. sending just ":") means "remove the range, play + everything". A song that is currently playing cannot be manipulated + this way. + """ + raise exceptions.MpdNotImplemented # TODO + + @protocol.commands.add('shuffle', songrange=protocol.RANGE) def shuffle(context, songrange=None): """ diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 3b7540b5..7bd4157a 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -386,6 +386,13 @@ class PlChangeCommandTest(BasePopulatedTracklistTestCase): self.assertInResponse('OK') +class RangeIdCommandTest(protocol.BaseTestCase): + + def test_rangeid(self): + self.send_request('rangeid 17 0:30') + self.assertEqualResponse('ACK [0@0] {rangeid} Not implemented') + + # TODO: we only seem to be testing that don't touch the non shuffled region :/ class ShuffleCommandTest(BasePopulatedTracklistTestCase): From 2f8d6253243a6264217794f53deab121448ae26a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 14:12:13 +0200 Subject: [PATCH 300/318] docs: Add missing image --- docs/ext/material_webclient.png | Bin 0 -> 42611 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/ext/material_webclient.png diff --git a/docs/ext/material_webclient.png b/docs/ext/material_webclient.png new file mode 100644 index 0000000000000000000000000000000000000000..1f8b13f89dd723516fd006e2c96a84fb5330be78 GIT binary patch literal 42611 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

g6fCKep(sVnV7-5)0Mwy6S~ISf+S6 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 UN7i$|Qvm$vY2VcPpoxn7FU3b Date: Tue, 21 Jul 2015 14:12:13 +0200 Subject: [PATCH 301/318] docs: Add missing image (cherry picked from commit 2f8d6253243a6264217794f53deab121448ae26a) --- docs/ext/material_webclient.png | Bin 0 -> 42611 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/ext/material_webclient.png diff --git a/docs/ext/material_webclient.png b/docs/ext/material_webclient.png new file mode 100644 index 0000000000000000000000000000000000000000..1f8b13f89dd723516fd006e2c96a84fb5330be78 GIT binary patch literal 42611 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

g6fCKep(sVnV7-5)0Mwy6S~ISf+S6 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 UN7i$|Qvm$vY2VcPpoxn7FU3b Date: Tue, 21 Jul 2015 14:18:28 +0200 Subject: [PATCH 302/318] mpd: Add prio/prioid command skeleton --- mopidy/mpd/protocol/current_playlist.py | 14 +++++++------- tests/mpd/protocol/test_current_playlist.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 6eeeccf5..1b1d3bbd 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -324,9 +324,8 @@ def plchangesposid(context, version): return result -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add( -# 'prio', priority=protocol.UINT, position=protocol.RANGE) +@protocol.commands.add( + 'prio', priority=protocol.UINT, position=protocol.RANGE) def prio(context, priority, position): """ *musicpd.org, current playlist section:* @@ -339,11 +338,10 @@ def prio(context, priority, position): A priority is an integer between 0 and 255. The default priority of new songs is 0. """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('prioid') +@protocol.commands.add('prioid') def prioid(context, *args): """ *musicpd.org, current playlist section:* @@ -352,7 +350,7 @@ def prioid(context, *args): Same as prio, but address the songs with their id. """ - pass + raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('rangeid', tlid=protocol.UINT, songrange=protocol.RANGE) @@ -367,6 +365,8 @@ def rangeid(context, tlid, songrange): Omitting both (i.e. sending just ":") means "remove the range, play everything". A song that is currently playing cannot be manipulated this way. + + .. versionadded:: MPD protocol 0.19 """ raise exceptions.MpdNotImplemented # TODO diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 7bd4157a..6f3961fa 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -386,6 +386,17 @@ class PlChangeCommandTest(BasePopulatedTracklistTestCase): self.assertInResponse('OK') +class PrioCommandTest(protocol.BaseTestCase): + + def test_prio(self): + self.send_request('prio 255 0:10') + self.assertEqualResponse('ACK [0@0] {prio} Not implemented') + + def test_prioid(self): + self.send_request('prioid 255 17 23') + self.assertEqualResponse('ACK [0@0] {prioid} Not implemented') + + class RangeIdCommandTest(protocol.BaseTestCase): def test_rangeid(self): From 21a3b74e9b139c39b5b7f58601d45a3adf8ea2e0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 14:30:18 +0200 Subject: [PATCH 303/318] mpd: Add addtagid/cleartagid command skeleton --- docs/changelog.rst | 2 ++ mopidy/mpd/protocol/current_playlist.py | 14 ++++++++------ tests/mpd/protocol/test_current_playlist.py | 11 +++++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index eebc3b0c..bbf03bfb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -61,6 +61,8 @@ MPD frontend implemented" error: - ``rangeid`` + - ``addtagid`` + - ``cleartagid`` File backend ------------ diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 1b1d3bbd..0433bba7 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -430,8 +430,7 @@ def swapid(context, tlid1, tlid2): swap(context, position1, position2) -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('addtagid', tlid=protocol.UINT) +@protocol.commands.add('addtagid', tlid=protocol.UINT) def addtagid(context, tlid, tag, value): """ *musicpd.org, current playlist section:* @@ -442,12 +441,13 @@ def addtagid(context, tlid, tag, value): for remote songs. This change is volatile: it may be overwritten by tags received from the server, and the data is gone when the song gets removed from the queue. + + .. versionadded:: MPD protocol 0.19 """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('cleartagid', tlid=protocol.UINT) +@protocol.commands.add('cleartagid', tlid=protocol.UINT) def cleartagid(context, tlid, tag): """ *musicpd.org, current playlist section:* @@ -457,5 +457,7 @@ def cleartagid(context, tlid, tag): Removes tags from the specified song. If TAG is not specified, then all tag values will be removed. Editing song tags is only possible for remote songs. + + .. versionadded:: MPD protocol 0.19 """ - pass + raise exceptions.MpdNotImplemented # TODO diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 6f3961fa..81bec5a4 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -459,3 +459,14 @@ class SwapCommandTest(BasePopulatedTracklistTestCase): self.send_request('swapid "8" "0"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') + + +class TagCommandTest(protocol.BaseTestCase): + + def test_addtagid(self): + self.send_request('addtagid 17 artist Abba') + self.assertEqualResponse('ACK [0@0] {addtagid} Not implemented') + + def test_cleartagid(self): + self.send_request('cleartagid 17 artist') + self.assertEqualResponse('ACK [0@0] {cleartagid} Not implemented') From 2135b1372a43a2d2e116ade5c9f8e3a4824425ea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 14:50:51 +0200 Subject: [PATCH 304/318] mpd: Add mount/unmount/listmounts/listneighbors command skeletons --- docs/changelog.rst | 8 +++- docs/modules/mpd.rst | 8 ++++ mopidy/mpd/protocol/__init__.py | 3 +- mopidy/mpd/protocol/mount.py | 78 ++++++++++++++++++++++++++++++++ tests/mpd/protocol/test_mount.py | 22 +++++++++ 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 mopidy/mpd/protocol/mount.py create mode 100644 tests/mpd/protocol/test_mount.py diff --git a/docs/changelog.rst b/docs/changelog.rst index bbf03bfb..60fcafbd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,12 +57,16 @@ MPD frontend instead of "A, B". This is a part of updating our protocol implementation to match MPD 0.19. (PR: :issue:`1213`) -- Add skeletons of new or otherwise missing MPD commands that return a "not - implemented" error: +- Add "not implemented" skeletons of new commands in the MPD protocol version + 0.19: - ``rangeid`` - ``addtagid`` - ``cleartagid`` + - ``mount`` + - ``unmount`` + - ``listmounts`` + - ``listneighbors`` File backend ------------ diff --git a/docs/modules/mpd.rst b/docs/modules/mpd.rst index 83650c39..7beadfdd 100644 --- a/docs/modules/mpd.rst +++ b/docs/modules/mpd.rst @@ -71,6 +71,14 @@ Current playlist :members: +Mounts and neighbors +-------------------- + +.. automodule:: mopidy.mpd.protocol.mount + :synopsis: MPD protocol: mounts and neighbors + :members: + + Music database -------------- diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index b69d5a2a..99294f4d 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -33,7 +33,8 @@ def load_protocol_modules(): """ from . import ( # noqa audio_output, channels, command_list, connection, current_playlist, - music_db, playback, reflection, status, stickers, stored_playlists) + mount, music_db, playback, reflection, status, stickers, + stored_playlists) def INT(value): # noqa: N802 diff --git a/mopidy/mpd/protocol/mount.py b/mopidy/mpd/protocol/mount.py new file mode 100644 index 00000000..bb9e7ed6 --- /dev/null +++ b/mopidy/mpd/protocol/mount.py @@ -0,0 +1,78 @@ +from __future__ import absolute_import, unicode_literals + +from mopidy.mpd import exceptions, protocol + + +@protocol.commands.add('mount') +def mount(context, path, uri): + """ + *musicpd.org, mounts and neighbors section:* + + ``mount {PATH} {URI}`` + + Mount the specified remote storage URI at the given path. Example:: + + mount foo nfs://192.168.1.4/export/mp3 + + .. versionadded:: MPD protocol 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + +@protocol.commands.add('unmount') +def unmount(context, path): + """ + *musicpd.org, mounts and neighbors section:* + + ``unmount {PATH}`` + + Unmounts the specified path. Example:: + + unmount foo + + .. versionadded:: MPD protocol 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + +@protocol.commands.add('listmounts') +def listmounts(context): + """ + *musicpd.org, mounts and neighbors section:* + + ``listmounts`` + + Queries a list of all mounts. By default, this contains just the + configured music_directory. Example:: + + listmounts + mount: + storage: /home/foo/music + mount: foo + storage: nfs://192.168.1.4/export/mp3 + OK + + .. versionadded:: MPD protocol 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + +@protocol.commands.add('listneighbors') +def listneighbors(context): + """ + *musicpd.org, mounts and neighbors section:* + + ``listneighbors`` + + Queries a list of "neighbors" (e.g. accessible file servers on the + local net). Items on that list may be used with the mount command. + Example:: + + listneighbors + neighbor: smb://FOO + name: FOO (Samba 4.1.11-Debian) + OK + + .. versionadded:: MPD protocol 0.19 + """ + raise exceptions.MpdNotImplemented # TODO diff --git a/tests/mpd/protocol/test_mount.py b/tests/mpd/protocol/test_mount.py new file mode 100644 index 00000000..c599ff46 --- /dev/null +++ b/tests/mpd/protocol/test_mount.py @@ -0,0 +1,22 @@ +from __future__ import absolute_import, unicode_literals + +from tests.mpd import protocol + + +class MountTest(protocol.BaseTestCase): + + def test_mount(self): + self.send_request('mount my_disk /dev/sda') + self.assertEqualResponse('ACK [0@0] {mount} Not implemented') + + def test_unmount(self): + self.send_request('unmount my_disk') + self.assertEqualResponse('ACK [0@0] {unmount} Not implemented') + + def test_listmounts(self): + self.send_request('listmounts') + self.assertEqualResponse('ACK [0@0] {listmounts} Not implemented') + + def test_listneighbors(self): + self.send_request('listneighbors') + self.assertEqualResponse('ACK [0@0] {listneighbors} Not implemented') From c88cf5ee82266f7131582f1a97cf92f6e0672dc7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 15:03:12 +0200 Subject: [PATCH 305/318] mpd: Add listfiles command skeleton --- docs/changelog.rst | 23 ++++++++++++++++------- mopidy/mpd/protocol/music_db.py | 23 +++++++++++++++++++++++ tests/mpd/protocol/test_music_db.py | 4 ++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 60fcafbd..bf09b81e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -60,13 +60,22 @@ MPD frontend - Add "not implemented" skeletons of new commands in the MPD protocol version 0.19: - - ``rangeid`` - - ``addtagid`` - - ``cleartagid`` - - ``mount`` - - ``unmount`` - - ``listmounts`` - - ``listneighbors`` + - Current playlist: + + - ``rangeid`` + - ``addtagid`` + - ``cleartagid`` + + - Mounts and neighbors: + + - ``mount`` + - ``unmount`` + - ``listmounts`` + - ``listneighbors`` + + - Music DB: + + - ``listfiles`` File backend ------------ diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index f9d77d5b..ddd8f9f7 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -349,6 +349,29 @@ def listallinfo(context, uri=None): return result +@protocol.commands.add('listfiles') +def listfiles(context, uri=None): + """ + *musicpd.org, music database section:* + + ``listfiles [URI]`` + + Lists the contents of the directory URI, including files are not + recognized by MPD. URI can be a path relative to the music directory or + an URI understood by one of the storage plugins. The response contains + at least one line for each directory entry with the prefix "file: " or + "directory: ", and may be followed by file attributes such as + "Last-Modified" and "size". + + For example, "smb://SERVER" returns a list of all shares on the given + SMB/CIFS server; "nfs://servername/path" obtains a directory listing + from the NFS server. + + .. versionadded:: MPD protocol 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + @protocol.commands.add('lsinfo') def lsinfo(context, uri=None): """ diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index b1f5f7c8..5fe40e0d 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -295,6 +295,10 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') + def test_listfiles(self): + self.send_request('listfiles') + self.assertEqualResponse('ACK [0@0] {listfiles} Not implemented') + def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = 1390942873222 self.backend.playlists.set_dummy_playlists([ From 16b48e51e26ec609af7e290122903b6e7f673bfa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 15:07:02 +0200 Subject: [PATCH 306/318] mpd: Remove old warnings about quotes --- mopidy/mpd/protocol/current_playlist.py | 5 ----- mopidy/mpd/protocol/music_db.py | 10 ---------- 2 files changed, 15 deletions(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 0433bba7..f9de250a 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -183,10 +183,6 @@ def playlistfind(context, tag, needle): ``playlistfind {TAG} {NEEDLE}`` Finds songs in the current playlist with strict matching. - - *GMPC:* - - - does not add quotes around the tag. """ if tag == 'filename': tl_tracks = context.core.tracklist.filter({'uri': [needle]}).get() @@ -260,7 +256,6 @@ def playlistsearch(context, tag, needle): *GMPC:* - - does not add quotes around the tag - uses ``filename`` and ``any`` as tags """ raise exceptions.MpdNotImplemented # TODO diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index ddd8f9f7..e51a7e22 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -96,7 +96,6 @@ def count(context, *args): *GMPC:* - - does not add quotes around the tag argument. - use multiple tag-needle pairs to make more specific searches. """ try: @@ -125,13 +124,11 @@ def find(context, *args): *GMPC:* - - does not add quotes around the field argument. - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album tracks. *ncmpc:* - - does not add quotes around the field argument. - capitalizes the type argument. *ncmpcpp:* @@ -255,13 +252,8 @@ def list_(context, *args): Genre: Rock OK - *GMPC:* - - - does not add quotes around the field argument. - *ncmpc:* - - does not add quotes around the field argument. - capitalizes the field argument. """ params = list(args) @@ -428,7 +420,6 @@ def search(context, *args): *GMPC:* - - does not add quotes around the field argument. - uses the undocumented field ``any``. - searches for multiple words like this:: @@ -436,7 +427,6 @@ def search(context, *args): *ncmpc:* - - does not add quotes around the field argument. - capitalizes the field argument. *ncmpcpp:* From bcbafb29e3577209198df46a53f47b10bba05cd9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 17:14:50 +0200 Subject: [PATCH 307/318] config: Enable core config section --- mopidy/config/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 8d3fa376..13a26412 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -47,8 +47,9 @@ _proxy_schema['password'] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema # _outputs_schema = config.AudioOutputConfigSchema() -_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema, - _audio_schema, _proxy_schema] +_schemas = [ + _core_schema, _logging_schema, _loglevels_schema, _logcolors_schema, + _audio_schema, _proxy_schema] _INITIAL_HELP = """ # For further information about options in this file see: From fb859a9f238ef7d72420c049cd9cd0e8c478d2bc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 21 Jul 2015 18:31:19 +0200 Subject: [PATCH 308/318] m3u: Fix crash if playlist filename is not decodable ...with the current file system encoding Fixes #1209 --- docs/changelog.rst | 4 ++++ mopidy/m3u/playlists.py | 2 +- tests/m3u/test_playlists.py | 18 +++++++++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a811f42..2693201f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,10 @@ Bug fix release. - Fix reversal of ``Title`` and ``Name`` in MPD protocol (Fixes: :issue:`1212` PR: :issue:`1214`) +- Fix crash if an M3U file in the :confval:`m3u/playlist_dir` directory has a + file name not decodable with the current file system encoding. (Fixes: + :issue:`1209`) + v1.0.7 (2015-06-26) =================== diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index 8800e468..33281129 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -66,7 +66,7 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): relpath = os.path.basename(path) uri = translator.path_to_playlist_uri(relpath) - name = os.path.splitext(relpath)[0].decode(encoding) + name = os.path.splitext(relpath)[0].decode(encoding, 'replace') tracks = translator.parse_m3u(path) playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index b7ac827f..31d8f100 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -1,9 +1,12 @@ +# encoding: utf-8 + from __future__ import absolute_import, unicode_literals import os import shutil import tempfile import unittest +import urllib import pykka @@ -108,6 +111,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): with open(path) as f: m3u = f.read().splitlines() + self.assertEqual(['#EXTM3U', '#EXTINF:60,Test', track.uri], m3u) def test_latin1_playlist_contents_is_written_to_disk(self): @@ -142,9 +146,17 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) - @unittest.SkipTest - def test_santitising_of_playlist_filenames(self): - pass + def test_load_playlist_with_nonfilesystem_encoding_of_filename(self): + uri = 'm3u:%s.m3u' % urllib.quote('øæå'.encode('latin-1')) + path = playlist_uri_to_path(uri, self.playlists_dir) + with open(path, 'wb+') as f: + f.write(b'#EXTM3U\n') + + self.core.playlists.refresh() + + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup(uri) + self.assertEqual('\ufffd\ufffd\ufffd', result.name) @unittest.SkipTest def test_playlists_dir_is_created(self): From 6ed9b07aaac64f94d36b40582a10a7a303df501c Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Tue, 21 Jul 2015 18:20:17 +0100 Subject: [PATCH 309/318] Fix #1218 Output the last_modified timestamp from mopidy's track model to mpd clients in the same format as mpd uses - yyyy-mm-ddTHH:MM:SS Outputs nothing for Last-Modified if last_modified is None or zero This commit uses UTC time, adds a 'Z' to end, and updates the test accordingly --- mopidy/mpd/translator.py | 6 +++--- tests/mpd/test_translator.py | 9 +-------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 0e7cb1ff..92e5a3ae 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -87,10 +87,10 @@ def track_to_mpd_format(track, position=None, stream_title=None): if track.disc_no: result.append(('Disc', track.disc_no)) - if track.last_modified is not None: - datestring = datetime.datetime.fromtimestamp( + if track.last_modified: + datestring = datetime.datetime.utcfromtimestamp( track.last_modified // 1000).isoformat() - result.append(('Last-Modified', datestring)) + result.append(('Last-Modified', datestring + 'Z')) if track.musicbrainz_id is not None: result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 3488442e..9c38a377 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, unicode_literals -import datetime import unittest from mopidy.internal import path @@ -82,12 +81,6 @@ class TrackMpdFormatTest(unittest.TestCase): def test_track_to_mpd_format_with_last_modified(self): track = self.track.replace(last_modified=995303899000) - # Due to this being local time-zone dependant, we have - # to calculate what the Last-Modified output will look like - # on the machine where the tests are being run. So this only tests - # that the output is actually there. - datestring = datetime.datetime.fromtimestamp( - track.last_modified // 1000).isoformat() result = translator.track_to_mpd_format(track) self.assertIn(('file', 'a uri'), result) self.assertIn(('Time', 137), result) @@ -101,7 +94,7 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertIn(('Track', '7/13'), result) self.assertIn(('Date', '1977-01-01'), result) self.assertIn(('Disc', 1), result) - self.assertIn(('Last-Modified', datestring), result) + self.assertIn(('Last-Modified', '2001-07-16T17:18:19Z'), result) self.assertNotIn(('Comment', 'a comment'), result) self.assertEqual(len(result), 13) From eab3076235f7fde4eebe94da8cab1afa814162f6 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Tue, 21 Jul 2015 21:13:38 +0100 Subject: [PATCH 310/318] Add test for track.last_modified = 0 --- tests/mpd/test_translator.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 9c38a377..066bacad 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -98,6 +98,24 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertNotIn(('Comment', 'a comment'), result) self.assertEqual(len(result), 13) + def test_track_to_mpd_format_with_last_modified_of_zero(self): + track = self.track.replace(last_modified=0) + result = translator.track_to_mpd_format(track) + self.assertIn(('file', 'a uri'), result) + self.assertIn(('Time', 137), result) + self.assertIn(('Artist', 'an artist'), result) + self.assertIn(('Title', 'a name'), result) + self.assertIn(('Album', 'an album'), result) + self.assertIn(('AlbumArtist', 'an other artist'), result) + self.assertIn(('Composer', 'a composer'), result) + self.assertIn(('Performer', 'a performer'), result) + self.assertIn(('Genre', 'a genre'), result) + self.assertIn(('Track', '7/13'), result) + self.assertIn(('Date', '1977-01-01'), result) + self.assertIn(('Disc', 1), result) + self.assertNotIn(('Comment', 'a comment'), result) + self.assertEqual(len(result), 12) + def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.replace(musicbrainz_id='foo') result = translator.track_to_mpd_format(track) From 6cb48f29ceeb74a030c4f8086c9e1ded085bd925 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 22 Jul 2015 11:21:04 +0200 Subject: [PATCH 311/318] mpd: Simplify Last-Modified test, update changelog --- docs/changelog.rst | 3 +++ tests/mpd/test_translator.py | 30 ++---------------------------- 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0f8823d3..735422fb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -77,6 +77,9 @@ MPD frontend - ``listfiles`` +- Track data now include the ``Last-Modified`` field if set on the track model. + (Fixes: :issue:`1218`, PR: :issue:`1219`) + File backend ------------ diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index a8255716..6a0220a8 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -82,39 +82,13 @@ class TrackMpdFormatTest(unittest.TestCase): def test_track_to_mpd_format_with_last_modified(self): track = self.track.replace(last_modified=995303899000) result = translator.track_to_mpd_format(track) - self.assertIn(('file', 'a uri'), result) - self.assertIn(('Time', 137), result) - self.assertIn(('Artist', 'an artist'), result) - self.assertIn(('Title', 'a name'), result) - self.assertIn(('Album', 'an album'), result) - self.assertIn(('AlbumArtist', 'an other artist'), result) - self.assertIn(('Composer', 'a composer'), result) - self.assertIn(('Performer', 'a performer'), result) - self.assertIn(('Genre', 'a genre'), result) - self.assertIn(('Track', '7/13'), result) - self.assertIn(('Date', '1977-01-01'), result) - self.assertIn(('Disc', 1), result) self.assertIn(('Last-Modified', '2001-07-16T17:18:19Z'), result) - self.assertNotIn(('Comment', 'a comment'), result) - self.assertEqual(len(result), 13) def test_track_to_mpd_format_with_last_modified_of_zero(self): track = self.track.replace(last_modified=0) result = translator.track_to_mpd_format(track) - self.assertIn(('file', 'a uri'), result) - self.assertIn(('Time', 137), result) - self.assertIn(('Artist', 'an artist'), result) - self.assertIn(('Title', 'a name'), result) - self.assertIn(('Album', 'an album'), result) - self.assertIn(('AlbumArtist', 'an other artist'), result) - self.assertIn(('Composer', 'a composer'), result) - self.assertIn(('Performer', 'a performer'), result) - self.assertIn(('Genre', 'a genre'), result) - self.assertIn(('Track', '7/13'), result) - self.assertIn(('Date', '1977-01-01'), result) - self.assertIn(('Disc', 1), result) - self.assertNotIn(('Comment', 'a comment'), result) - self.assertEqual(len(result), 12) + keys = [k for k, v in result] + self.assertNotIn('Last-Modified', keys) def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.replace(musicbrainz_id='foo') From b32db58f729447e82fb2aee83eb23c0aec87dea3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 22 Jul 2015 11:30:19 +0200 Subject: [PATCH 312/318] docs: Review versionadded/versionchanged usage Fixes #1166 --- mopidy/core/tracklist.py | 4 ++-- mopidy/mpd/protocol/current_playlist.py | 9 ++++++--- mopidy/mpd/protocol/mount.py | 12 ++++++++---- mopidy/mpd/protocol/music_db.py | 3 ++- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 63a38eae..1938f001 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -214,8 +214,8 @@ class TracklistController(object): :type tlid: :class:`int` or :class:`None` :rtype: :class:`int` or :class:`None` - .. versionchanged:: 1.1 - Added the *tlid* parameter + .. versionadded:: 1.1 + The *tlid* parameter """ tl_track is None or validation.check_instance(tl_track, TlTrack) tlid is None or validation.check_integer(tlid, min=0) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index f9de250a..0d07452c 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -361,7 +361,8 @@ def rangeid(context, tlid, songrange): everything". A song that is currently playing cannot be manipulated this way. - .. versionadded:: MPD protocol 0.19 + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @@ -437,7 +438,8 @@ def addtagid(context, tlid, tag, value): tags received from the server, and the data is gone when the song gets removed from the queue. - .. versionadded:: MPD protocol 0.19 + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @@ -453,6 +455,7 @@ def cleartagid(context, tlid, tag): tag values will be removed. Editing song tags is only possible for remote songs. - .. versionadded:: MPD protocol 0.19 + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO diff --git a/mopidy/mpd/protocol/mount.py b/mopidy/mpd/protocol/mount.py index bb9e7ed6..f9a0d75f 100644 --- a/mopidy/mpd/protocol/mount.py +++ b/mopidy/mpd/protocol/mount.py @@ -14,7 +14,8 @@ def mount(context, path, uri): mount foo nfs://192.168.1.4/export/mp3 - .. versionadded:: MPD protocol 0.19 + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @@ -30,7 +31,8 @@ def unmount(context, path): unmount foo - .. versionadded:: MPD protocol 0.19 + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @@ -52,7 +54,8 @@ def listmounts(context): storage: nfs://192.168.1.4/export/mp3 OK - .. versionadded:: MPD protocol 0.19 + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @@ -73,6 +76,7 @@ def listneighbors(context): name: FOO (Samba 4.1.11-Debian) OK - .. versionadded:: MPD protocol 0.19 + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index e51a7e22..00db0218 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -359,7 +359,8 @@ def listfiles(context, uri=None): SMB/CIFS server; "nfs://servername/path" obtains a directory listing from the NFS server. - .. versionadded:: MPD protocol 0.19 + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO From 131d992bed343dbb5aa8b01c2a82bc5779f6b259 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 22 Jul 2015 11:59:59 +0200 Subject: [PATCH 313/318] local: Filter out None from get_distinct results Fixes #1202 --- docs/changelog.rst | 7 +++++++ mopidy/local/json.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 735422fb..0aedd763 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -80,6 +80,13 @@ MPD frontend - Track data now include the ``Last-Modified`` field if set on the track model. (Fixes: :issue:`1218`, PR: :issue:`1219`) +Local backend +------------- + +- Filter out :class:`None` from + :meth:`~mopidy.backend.LibraryProvider.get_distinct` results. All returned + results should be strings. (Fixes: :issue:`1202`) + File backend ------------ diff --git a/mopidy/local/json.py b/mopidy/local/json.py index bc2ca775..0be5e99e 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -174,7 +174,7 @@ class JsonLibrary(local.Library): search_result = search.search(self._tracks.values(), query, limit=None) for track in search_result.tracks: distinct_result.update(distinct(track)) - return distinct_result + return distinct_result - {None} def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() From 0ebfeb5a5bf1798e5a13cecb93be08563acdff8f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 22 Jul 2015 12:14:04 +0200 Subject: [PATCH 314/318] core: Normalize negative seek positions This reverts a change between 1.0 and 1.1, so no changelog. Fixes #1180 --- mopidy/core/playback.py | 7 ++++++- tests/core/test_playback.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0350c763..f374127a 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -399,7 +399,12 @@ class PlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - validation.check_integer(time_position, min=0) + validation.check_integer(time_position) + + if time_position < 0: + logger.debug( + 'Client seeked to negative position. Seeking to zero.') + time_position = 0 if not self.core.tracklist.tracks: return False diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index c4ba01a6..0e51c4db 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -536,6 +536,12 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.seek.called) self.playback2.seek.assert_called_once_with(10000) + def test_seek_normalizes_negative_positions_to_zero(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.seek(-100) + + self.playback1.seek.assert_called_once_with(0) + def test_seek_fails_for_unplayable_track(self): self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PLAYING From cf9c9509154b0b9f40781de3797a66fe7f3003ab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 22 Jul 2015 18:30:41 +0200 Subject: [PATCH 315/318] Bump version to 1.0.8 --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 7ab3b9e6..e9f2e6c3 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.7' +__version__ = '1.0.8' diff --git a/tests/test_version.py b/tests/test_version.py index f8afd3db..c0c2d9e6 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -62,5 +62,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('1.0.3', '1.0.4') self.assertVersionLess('1.0.4', '1.0.5') self.assertVersionLess('1.0.5', '1.0.6') - self.assertVersionLess('1.0.6', __version__) - self.assertVersionLess(__version__, '1.0.8') + self.assertVersionLess('1.0.6', '1.0.7') + self.assertVersionLess('1.0.7', __version__) + self.assertVersionLess(__version__, '1.0.9') From 9a934c6e9cd61f48149d39156d23ac028a2ffc9f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 22 Jul 2015 18:31:09 +0200 Subject: [PATCH 316/318] docs: Update changelog for v1.0.8 --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2693201f..c966cc45 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,7 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.8 (unreleased) +v1.0.8 (2015-07-22) =================== Bug fix release. From d74e40f5e8d9e316ce2f2ab7e8856c6e9541b329 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 22 Jul 2015 18:41:54 +0200 Subject: [PATCH 317/318] docs: git can now push with tags in one command --- docs/releasing.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/releasing.rst b/docs/releasing.rst index 8a12cf7d..4c2d8373 100644 --- a/docs/releasing.rst +++ b/docs/releasing.rst @@ -47,8 +47,7 @@ Creating releases #. Push to GitHub:: - git push - git push --tags + git push --follow-tags #. Upload the previously built and tested sdist and bdist_wheel packages to PyPI:: From edd7afb1745525e64489b12f9c585771506b5edf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 22 Jul 2015 19:31:02 +0200 Subject: [PATCH 318/318] docs: Add some notes about validation that are performed --- docs/api/core.rst | 6 ++++++ docs/api/models.rst | 2 +- docs/extensiondev.rst | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index 9134afed..5f1e406f 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -13,6 +13,12 @@ frontends and the backends. Don't forget that you will be accessing core as a Pykka actor. If you are only interested in being notified about changes in core see :class:`~mopidy.core.CoreListener`. +.. versionchanged:: 1.1 + All core API calls are now type checked. + +.. versionchanged:: 1.1 + All backend return values are now type checked. + .. autoclass:: mopidy.core.Core .. attribute:: tracklist diff --git a/docs/api/models.rst b/docs/api/models.rst index 07702555..27c7647f 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -5,7 +5,7 @@ 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 and immutable. In other words, they can only be set through the class -constructor during instance creation. +constructor during instance creation. Additionally fields are type checked. If you want to modify a model, use the :meth:`~mopidy.models.ImmutableObject.replace` method. It accepts keyword diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 034e0b54..340a18da 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -437,6 +437,9 @@ When writing an extension, you should only use APIs documented at :ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.internal`, may change at any time and are not something extensions should use. +Mopidy performs type checking to help catch extension bugs. This applies to +both to frontend calls into core and return values from backends. Additionally +model fields always get validated to further guard against bad data. Logging in extensions =====================