Merge branch 'develop' of http://github.com/jodal/mopidy into develop

This commit is contained in:
Thomas Adamcik 2010-10-26 22:21:06 +02:00
commit 0371698706
31 changed files with 770 additions and 236 deletions

View File

@ -11,8 +11,9 @@ platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones.
To install Mopidy, check out To install Mopidy, check out
`the installation docs <http://www.mopidy.com/docs/master/installation/>`_. `the installation docs <http://www.mopidy.com/docs/master/installation/>`_.
* `Documentation <http://www.mopidy.com/docs/master/>`_ * `Documentation (latest release) <http://www.mopidy.com/docs/master/>`_
* `Documentation (development version) <http://www.mopidy.com/docs/develop/>`_ * `Documentation (development version) <http://www.mopidy.com/docs/develop/>`_
* `Source code <http://github.com/jodal/mopidy>`_ * `Source code <http://github.com/jodal/mopidy>`_
* `Issue tracker <http://github.com/jodal/mopidy/issues>`_ * `Issue tracker <http://github.com/jodal/mopidy/issues>`_
* IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_ * IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
* `Download development snapshot <http://github.com/jodal/mopidy/tarball/develop#egg=mopidy-dev>`_

View File

@ -5,31 +5,82 @@ Changes
This change log is used to track all major changes to Mopidy. This change log is used to track all major changes to Mopidy.
0.2.0 (in development) 0.3.0 (in development)
====================== ======================
No description yet. No description yet.
**Changes**
- None so far.
0.2.0 (2010-10-24)
==================
In Mopidy 0.2.0 we've added a `Last.fm <http://www.last.fm/>`_ scrobbling
support, which means that Mopidy now can submit meta data about the tracks you
play to your Last.fm profile. See :mod:`mopidy.frontends.lastfm` for
details on new dependencies and settings. If you use Mopidy's Last.fm support,
please join the `Mopidy group at Last.fm <http://www.last.fm/group/Mopidy>`_.
With the exception of the work on the Last.fm scrobbler, there has been a
couple of quiet months in the Mopidy camp. About the only thing going on, has
been stabilization work and bug fixing. All bugs reported on GitHub, plus some,
have been fixed in 0.2.0. Thus, we hope this will be a great release!
We've worked a bit on OS X support, but not all issues are completely solved
yet. :issue:`25` is the one that is currently blocking OS X support. Any help
solving it will be greatly appreciated!
Finally, please :ref:`update your pyspotify installation
<pyspotify_installation>` when upgrading to Mopidy 0.2.0. The latest pyspotify
got a fix for the segmentation fault that occurred when playing music and
searching at the same time, thanks to Valentin David.
**Important changes** **Important changes**
- Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details. - Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details.
**Changes** **Changes**
- Simplify the default log format, :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. - Logging and command line options:
From a user's point of view: Less noise, more information.
- Rename the :option:`--dump` command line option to - Simplify the default log format,
:option:`--save-debug-log`. :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. From a user's point of view:
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to Less noise, more information.
:attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose` - Rename the :option:`--dump` command line option to
too. :option:`--save-debug-log`.
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to - Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to
:attr:`mopidy.settings.DEBUG_LOG_FILENAME`. :attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose`
- Switched from using subprocesses to threads. This partly fixes the OS X too.
support. See :issue:`14` for details. - Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to
:attr:`mopidy.settings.DEBUG_LOG_FILENAME`.
- MPD frontend: - MPD frontend:
- ``add ""`` and ``addid ""`` now behaves as expected. - MPD command ``list`` now supports queries by artist, album name, and date,
as used by e.g. the Ario client. (Fixes: :issue:`20`)
- MPD command ``add ""`` and ``addid ""`` now behaves as expected. (Fixes
:issue:`16`)
- MPD command ``playid "-1"`` now correctly resumes playback if paused.
- Random mode:
- Fix wrong behavior on end of track and next after random mode has been
used. (Fixes: :issue:`18`)
- Fix infinite recursion loop crash on playback of non-playable tracks when
in random mode. (Fixes :issue:`17`)
- Fix assertion error that happened if one removed tracks from the current
playlist, while in random mode. (Fixes :issue:`22`)
- Switched from using subprocesses to threads. (Fixes: :issue:`14`)
- :mod:`mopidy.outputs.gstreamer`: Set ``caps`` on the ``appsrc`` bin before
use. This makes sound output work with GStreamer >= 0.10.29, which includes
the versions used in Ubuntu 10.10 and on OS X if using Homebrew. (Fixes:
:issue:`21`, :issue:`24`, contributes to :issue:`14`)
- Improved handling of uncaught exceptions in threads. The entire process
should now exit immediately.
0.1.0 (2010-08-23) 0.1.0 (2010-08-23)

View File

@ -151,20 +151,25 @@ Then, to generate docs::
Creating releases Creating releases
================= =================
1. Update changelog and commit it. #. Update changelog and commit it.
2. Tag release:: #. Merge the release branch (``develop`` in the example) into master::
git tag -a -m "Release v0.1.0a0" v0.1.0a0 git checkout master
git merge --no-ff -m "Release v0.2.0" develop
3. Push to GitHub:: #. Tag the release::
git tag -a -m "Release v0.2.0" v0.2.0
#. Push to GitHub::
git push git push
git push --tags git push --tags
4. Build package and upload to PyPI:: #. Build package and upload to PyPI::
rm MANIFEST # Will be regenerated by setup.py rm MANIFEST # Will be regenerated by setup.py
python setup.py sdist upload python setup.py sdist upload
5. Spread the word. #. Spread the word.

View File

@ -3,7 +3,7 @@ if not (2, 6) <= sys.version_info < (3,):
sys.exit(u'Mopidy requires Python >= 2.6, < 3') sys.exit(u'Mopidy requires Python >= 2.6, < 3')
def get_version(): def get_version():
return u'0.2.0a1' return u'0.3.0'
class MopidyException(Exception): class MopidyException(Exception):
def __init__(self, message, *args, **kwargs): def __init__(self, message, *args, **kwargs):

View File

@ -12,13 +12,10 @@ class BaseCurrentPlaylistController(object):
:type backend: :class:`BaseBackend` :type backend: :class:`BaseBackend`
""" """
#: The current playlist version. Integer which is increased every time the
#: current playlist is changed. Is not reset before Mopidy is restarted.
version = 0
def __init__(self, backend): def __init__(self, backend):
self.backend = backend self.backend = backend
self._cp_tracks = [] self._cp_tracks = []
self._version = 0
def destroy(self): def destroy(self):
"""Cleanup after component.""" """Cleanup after component."""
@ -42,6 +39,19 @@ class BaseCurrentPlaylistController(object):
""" """
return [ct[1] for ct in self._cp_tracks] return [ct[1] for ct in self._cp_tracks]
@property
def version(self):
"""
The current playlist version. Integer which is increased every time the
current playlist is changed. Is not reset before Mopidy is restarted.
"""
return self._version
@version.setter
def version(self, version):
self._version = version
self.backend.playback.on_current_playlist_change()
def add(self, track, at_position=None): def add(self, track, at_position=None):
""" """
Add the track to the end of, or at the given position in the current Add the track to the end of, or at the given position in the current
@ -71,16 +81,13 @@ class BaseCurrentPlaylistController(object):
:param tracks: tracks to append :param tracks: tracks to append
:type tracks: list of :class:`mopidy.models.Track` :type tracks: list of :class:`mopidy.models.Track`
""" """
self.version += 1
for track in tracks: for track in tracks:
self.add(track) self.add(track)
self.backend.playback.on_current_playlist_change()
def clear(self): def clear(self):
"""Clear the current playlist.""" """Clear the current playlist."""
self._cp_tracks = [] self._cp_tracks = []
self.version += 1 self.version += 1
self.backend.playback.on_current_playlist_change()
def get(self, **criteria): def get(self, **criteria):
""" """
@ -146,7 +153,6 @@ class BaseCurrentPlaylistController(object):
to_position += 1 to_position += 1
self._cp_tracks = new_cp_tracks self._cp_tracks = new_cp_tracks
self.version += 1 self.version += 1
self.backend.playback.on_current_playlist_change()
def remove(self, **criteria): def remove(self, **criteria):
""" """
@ -191,7 +197,6 @@ class BaseCurrentPlaylistController(object):
random.shuffle(shuffled) random.shuffle(shuffled)
self._cp_tracks = before + shuffled + after self._cp_tracks = before + shuffled + after
self.version += 1 self.version += 1
self.backend.playback.on_current_playlist_change()
def mpd_format(self, *args, **kwargs): def mpd_format(self, *args, **kwargs):
"""Not a part of the generic backend API.""" """Not a part of the generic backend API."""

View File

@ -10,6 +10,9 @@ class BasePlaybackController(object):
:type backend: :class:`BaseBackend` :type backend: :class:`BaseBackend`
""" """
# pylint: disable = R0902
# Too many instance attributes
#: Constant representing the paused state. #: Constant representing the paused state.
PAUSED = u'paused' PAUSED = u'paused'
@ -130,6 +133,9 @@ class BasePlaybackController(object):
Not necessarily the same track as :attr:`cp_track_at_next`. Not necessarily the same track as :attr:`cp_track_at_next`.
""" """
# pylint: disable = R0911
# Too many return statements
cp_tracks = self.backend.current_playlist.cp_tracks cp_tracks = self.backend.current_playlist.cp_tracks
if not cp_tracks: if not cp_tracks:
@ -142,17 +148,16 @@ class BasePlaybackController(object):
random.shuffle(self._shuffled) random.shuffle(self._shuffled)
self._first_shuffle = False self._first_shuffle = False
if self._shuffled: if self.random and self._shuffled:
return self._shuffled[0] return self._shuffled[0]
if self.current_cp_track is None: if self.current_cp_track is None:
return cp_tracks[0] return cp_tracks[0]
if self.repeat and self.single: if self.repeat and self.single:
return cp_tracks[ return cp_tracks[self.current_playlist_position]
(self.current_playlist_position) % len(cp_tracks)]
if self.repeat: if self.repeat and not self.single:
return cp_tracks[ return cp_tracks[
(self.current_playlist_position + 1) % len(cp_tracks)] (self.current_playlist_position + 1) % len(cp_tracks)]
@ -195,7 +200,7 @@ class BasePlaybackController(object):
random.shuffle(self._shuffled) random.shuffle(self._shuffled)
self._first_shuffle = False self._first_shuffle = False
if self._shuffled: if self.random and self._shuffled:
return self._shuffled[0] return self._shuffled[0]
if self.current_cp_track is None: if self.current_cp_track is None:
@ -315,11 +320,8 @@ class BasePlaybackController(object):
if self.cp_track_at_eot: if self.cp_track_at_eot:
self._trigger_stopped_playing_event() self._trigger_stopped_playing_event()
self.play(self.cp_track_at_eot) self.play(self.cp_track_at_eot)
if self.random and self.current_cp_track in self._shuffled:
self._shuffled.remove(self.current_cp_track)
else: else:
self.stop() self.stop(clear_current_track=True)
self.current_cp_track = None
if self.consume: if self.consume:
self.backend.current_playlist.remove(cpid=original_cp_track[0]) self.backend.current_playlist.remove(cpid=original_cp_track[0])
@ -333,13 +335,10 @@ class BasePlaybackController(object):
self._first_shuffle = True self._first_shuffle = True
self._shuffled = [] self._shuffled = []
if not self.backend.current_playlist.cp_tracks: if (not self.backend.current_playlist.cp_tracks or
self.stop() self.current_cp_track not in
self.current_cp_track = None
elif (self.current_cp_track not in
self.backend.current_playlist.cp_tracks): self.backend.current_playlist.cp_tracks):
self.current_cp_track = None self.stop(clear_current_track=True)
self.stop()
def next(self): def next(self):
"""Play the next track.""" """Play the next track."""
@ -350,11 +349,7 @@ class BasePlaybackController(object):
self._trigger_stopped_playing_event() self._trigger_stopped_playing_event()
self.play(self.cp_track_at_next) self.play(self.cp_track_at_next)
else: else:
self.stop() self.stop(clear_current_track=True)
self.current_cp_track = None
if self.random and self.current_cp_track in self._shuffled:
self._shuffled.remove(self.current_cp_track)
def pause(self): def pause(self):
"""Pause playback.""" """Pause playback."""
@ -385,15 +380,21 @@ class BasePlaybackController(object):
if cp_track is not None: if cp_track is not None:
assert cp_track in self.backend.current_playlist.cp_tracks assert cp_track in self.backend.current_playlist.cp_tracks
elif not self.current_cp_track:
if cp_track is None and self.current_cp_track is None:
cp_track = self.cp_track_at_next cp_track = self.cp_track_at_next
if self.state == self.PAUSED and cp_track is None: if cp_track is None and self.state == self.PAUSED:
self.resume() self.resume()
elif cp_track is not None:
if cp_track is not None:
self.state = self.STOPPED
self.current_cp_track = cp_track self.current_cp_track = cp_track
self.state = self.PLAYING self.state = self.PLAYING
if not self._play(cp_track[1]): if not self._play(cp_track[1]):
# Track is not playable
if self.random and self._shuffled:
self._shuffled.remove(cp_track)
if on_error_step == 1: if on_error_step == 1:
self.next() self.next()
elif on_error_step == -1: elif on_error_step == -1:
@ -477,13 +478,21 @@ class BasePlaybackController(object):
""" """
raise NotImplementedError raise NotImplementedError
def stop(self): def stop(self, clear_current_track=False):
"""Stop playing.""" """
Stop playing.
:param clear_current_track: whether to clear the current track _after_
stopping
:type clear_current_track: boolean
"""
if self.state == self.STOPPED: if self.state == self.STOPPED:
return return
self._trigger_stopped_playing_event() self._trigger_stopped_playing_event()
if self._stop(): if self._stop():
self.state = self.STOPPED self.state = self.STOPPED
if clear_current_track:
self.current_cp_track = None
def _stop(self): def _stop(self):
""" """
@ -501,11 +510,12 @@ class BasePlaybackController(object):
For internal use only. Should be called by the backend directly after a For internal use only. Should be called by the backend directly after a
track has started playing. track has started playing.
""" """
self.backend.core_queue.put({ if self.current_track is not None:
'to': 'frontend', self.backend.core_queue.put({
'command': 'started_playing', 'to': 'frontend',
'track': self.current_track, 'command': 'started_playing',
}) 'track': self.current_track,
})
def _trigger_stopped_playing_event(self): def _trigger_stopped_playing_event(self):
""" """
@ -515,9 +525,10 @@ class BasePlaybackController(object):
is stopped playing, e.g. at the next, previous, and stop actions and at is stopped playing, e.g. at the next, previous, and stop actions and at
end-of-track. end-of-track.
""" """
self.backend.core_queue.put({ if self.current_track is not None:
'to': 'frontend', self.backend.core_queue.put({
'command': 'stopped_playing', 'to': 'frontend',
'track': self.current_track, 'command': 'stopped_playing',
'stop_position': self.time_position, 'track': self.current_track,
}) 'stop_position': self.time_position,
})

View File

@ -44,16 +44,19 @@ class DummyLibraryController(BaseLibraryController):
class DummyPlaybackController(BasePlaybackController): class DummyPlaybackController(BasePlaybackController):
def _next(self, track): def _next(self, track):
return True """Pass None as track to force failure"""
return track is not None
def _pause(self): def _pause(self):
return True return True
def _play(self, track): def _play(self, track):
return True """Pass None as track to force failure"""
return track is not None
def _previous(self, track): def _previous(self, track):
return True """Pass None as track to force failure"""
return track is not None
def _resume(self): def _resume(self):
return True return True

View File

@ -6,6 +6,7 @@ from spotify import Link, SpotifyError
from mopidy.backends.base import BaseLibraryController from mopidy.backends.base import BaseLibraryController
from mopidy.backends.libspotify import ENCODING from mopidy.backends.libspotify import ENCODING
from mopidy.backends.libspotify.translator import LibspotifyTranslator from mopidy.backends.libspotify.translator import LibspotifyTranslator
from mopidy.models import Playlist
logger = logging.getLogger('mopidy.backends.libspotify.library') logger = logging.getLogger('mopidy.backends.libspotify.library')
@ -28,15 +29,27 @@ class LibspotifyLibraryController(BaseLibraryController):
pass # TODO pass # TODO
def search(self, **query): def search(self, **query):
if not query:
# Since we can't search for the entire Spotify library, we return
# all tracks in the stored playlists when the query is empty.
tracks = []
for playlist in self.backend.stored_playlists.playlists:
tracks += playlist.tracks
return Playlist(tracks=tracks)
spotify_query = [] spotify_query = []
for (field, values) in query.iteritems(): for (field, values) in query.iteritems():
if field == u'track':
field = u'title'
if field == u'date':
field = u'year'
if not hasattr(values, '__iter__'): if not hasattr(values, '__iter__'):
values = [values] values = [values]
for value in values: for value in values:
if field == u'track':
field = u'title'
if field == u'any': if field == u'any':
spotify_query.append(value) spotify_query.append(value)
elif field == u'year':
value = int(value.split('-')[0]) # Extract year
spotify_query.append(u'%s:%d' % (field, value))
else: else:
spotify_query.append(u'%s:"%s"' % (field, value)) spotify_query.append(u'%s:"%s"' % (field, value))
spotify_query = u' '.join(spotify_query) spotify_query = u' '.join(spotify_query)

View File

@ -11,6 +11,9 @@ from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.backends.libspotify.session_manager') logger = logging.getLogger('mopidy.backends.libspotify.session_manager')
# pylint: disable = R0901
# LibspotifySessionManager: Too many ancestors (9/7)
class LibspotifySessionManager(SpotifySessionManager, BaseThread): class LibspotifySessionManager(SpotifySessionManager, BaseThread):
cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
@ -19,12 +22,8 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
def __init__(self, username, password, core_queue, output): def __init__(self, username, password, core_queue, output):
SpotifySessionManager.__init__(self, username, password) SpotifySessionManager.__init__(self, username, password)
BaseThread.__init__(self) BaseThread.__init__(self, core_queue)
self.name = 'LibspotifySMThread' self.name = 'LibspotifySMThread'
# Run as a daemon thread, so Mopidy won't wait for this thread to exit
# before Mopidy exits.
self.daemon = True
self.core_queue = core_queue
self.output = output self.output = output
self.connected = threading.Event() self.connected = threading.Event()
self.session = None self.session = None
@ -69,16 +68,21 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
def music_delivery(self, session, frames, frame_size, num_frames, def music_delivery(self, session, frames, frame_size, num_frames,
sample_type, sample_rate, channels): sample_type, sample_rate, channels):
"""Callback used by pyspotify""" """Callback used by pyspotify"""
# TODO Base caps_string on arguments # pylint: disable = R0913
# Too many arguments (8/5)
assert sample_type == 0, u'Expects 16-bit signed integer samples'
capabilites = """ capabilites = """
audio/x-raw-int, audio/x-raw-int,
endianness=(int)1234, endianness=(int)1234,
channels=(int)2, channels=(int)%(channels)d,
width=(int)16, width=(int)16,
depth=(int)16, depth=(int)16,
signed=True, signed=(boolean)true,
rate=(int)44100 rate=(int)%(sample_rate)d
""" """ % {
'sample_rate': sample_rate,
'channels': channels,
}
self.output.deliver_data(capabilites, bytes(frames)) self.output.deliver_data(capabilites, bytes(frames))
def play_token_lost(self, session): def play_token_lost(self, session):
@ -97,7 +101,7 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
def search(self, query, connection): def search(self, query, connection):
"""Search method used by Mopidy backend""" """Search method used by Mopidy backend"""
def callback(results, userdata): def callback(results, userdata=None):
# TODO Include results from results.albums(), etc. too # TODO Include results from results.albums(), etc. too
playlist = Playlist(tracks=[ playlist = Playlist(tracks=[
LibspotifyTranslator.to_mopidy_track(t) LibspotifyTranslator.to_mopidy_track(t)

View File

@ -5,7 +5,9 @@ import os
import shutil import shutil
from mopidy import settings from mopidy import settings
from mopidy.backends.base import * from mopidy.backends.base import (BaseBackend, BaseLibraryController,
BaseStoredPlaylistsController, BaseCurrentPlaylistController,
BasePlaybackController)
from mopidy.models import Playlist, Track, Album from mopidy.models import Playlist, Track, Album
from mopidy.utils.process import pickle_connection from mopidy.utils.process import pickle_connection

View File

@ -1,20 +1,22 @@
import logging import logging
import multiprocessing import multiprocessing
import optparse import optparse
import sys
from mopidy import get_version, settings, OptionalDependencyError from mopidy import get_version, settings, OptionalDependencyError
from mopidy.utils import get_class from mopidy.utils import get_class
from mopidy.utils.log import setup_logging from mopidy.utils.log import setup_logging
from mopidy.utils.path import get_or_create_folder, get_or_create_file from mopidy.utils.path import get_or_create_folder, get_or_create_file
from mopidy.utils.process import BaseProcess from mopidy.utils.process import BaseThread
from mopidy.utils.settings import list_settings_optparse_callback from mopidy.utils.settings import list_settings_optparse_callback
logger = logging.getLogger('mopidy.core') logger = logging.getLogger('mopidy.core')
class CoreProcess(BaseProcess): class CoreProcess(BaseThread):
def __init__(self): def __init__(self):
super(CoreProcess, self).__init__(name='CoreProcess')
self.core_queue = multiprocessing.Queue() self.core_queue = multiprocessing.Queue()
super(CoreProcess, self).__init__(self.core_queue)
self.name = 'CoreProcess'
self.options = self.parse_options() self.options = self.parse_options()
self.output = None self.output = None
self.backend = None self.backend = None
@ -79,7 +81,9 @@ class CoreProcess(BaseProcess):
return frontends return frontends
def process_message(self, message): def process_message(self, message):
if message.get('to') == 'output': if message.get('to') == 'core':
self.process_message_to_core(message)
elif message.get('to') == 'output':
self.output.process_message(message) self.output.process_message(message)
elif message.get('to') == 'frontend': elif message.get('to') == 'frontend':
for frontend in self.frontends: for frontend in self.frontends:
@ -92,3 +96,12 @@ class CoreProcess(BaseProcess):
self.backend.stored_playlists.playlists = message['playlists'] self.backend.stored_playlists.playlists = message['playlists']
else: else:
logger.warning(u'Cannot handle message: %s', message) logger.warning(u'Cannot handle message: %s', message)
def process_message_to_core(self, message):
assert message['to'] == 'core', u'Message recipient must be "core".'
if message['command'] == 'exit':
if message['reason'] is not None:
logger.info(u'Exiting (%s)', message['reason'])
sys.exit(message['status'])
else:
logger.warning(u'Cannot handle message: %s', message)

View File

@ -5,9 +5,9 @@ import time
try: try:
import pylast import pylast
except ImportError as e: except ImportError as import_error:
from mopidy import OptionalDependencyError from mopidy import OptionalDependencyError
raise OptionalDependencyError(e) raise OptionalDependencyError(import_error)
from mopidy import get_version, settings, SettingsError from mopidy import get_version, settings, SettingsError
from mopidy.frontends.base import BaseFrontend from mopidy.frontends.base import BaseFrontend
@ -45,7 +45,7 @@ class LastfmFrontend(BaseFrontend):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(LastfmFrontend, self).__init__(*args, **kwargs) super(LastfmFrontend, self).__init__(*args, **kwargs)
(self.connection, other_end) = multiprocessing.Pipe() (self.connection, other_end) = multiprocessing.Pipe()
self.thread = LastfmFrontendThread(other_end) self.thread = LastfmFrontendThread(self.core_queue, other_end)
def start(self): def start(self):
self.thread.start() self.thread.start()
@ -58,10 +58,9 @@ class LastfmFrontend(BaseFrontend):
class LastfmFrontendThread(BaseThread): class LastfmFrontendThread(BaseThread):
def __init__(self, connection): def __init__(self, core_queue, connection):
super(LastfmFrontendThread, self).__init__() super(LastfmFrontendThread, self).__init__(core_queue)
self.name = u'LastfmFrontendThread' self.name = u'LastfmFrontendThread'
self.daemon = True
self.connection = connection self.connection = connection
self.lastfm = None self.lastfm = None
self.scrobbler = None self.scrobbler = None
@ -84,7 +83,7 @@ class LastfmFrontendThread(BaseThread):
CLIENT_ID, CLIENT_VERSION) CLIENT_ID, CLIENT_VERSION)
logger.info(u'Connected to Last.fm') logger.info(u'Connected to Last.fm')
except SettingsError as e: except SettingsError as e:
logger.info(u'Last.fm scrobbler did not start.') logger.info(u'Last.fm scrobbler not started')
logger.debug(u'Last.fm settings error: %s', e) logger.debug(u'Last.fm settings error: %s', e)
except (pylast.WSError, socket.error) as e: except (pylast.WSError, socket.error) as e:
logger.error(u'Last.fm connection error: %s', e) logger.error(u'Last.fm connection error: %s', e)

View File

@ -5,9 +5,11 @@ from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError,
from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers
# Do not remove the following import. The protocol modules must be imported to # Do not remove the following import. The protocol modules must be imported to
# get them registered as request handlers. # get them registered as request handlers.
# pylint: disable = W0611
from mopidy.frontends.mpd.protocol import (audio_output, command_list, from mopidy.frontends.mpd.protocol import (audio_output, command_list,
connection, current_playlist, empty, music_db, playback, reflection, connection, current_playlist, empty, music_db, playback, reflection,
status, stickers, stored_playlists) status, stickers, stored_playlists)
# pylint: enable = W0611
from mopidy.utils import flatten from mopidy.utils import flatten
class MpdDispatcher(object): class MpdDispatcher(object):

View File

@ -1,7 +1,8 @@
import re import re
import shlex
from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists
from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented
def _build_query(mpd_query): def _build_query(mpd_query):
""" """
@ -81,13 +82,9 @@ def findadd(frontend, query):
# TODO Add result to current playlist # TODO Add result to current playlist
#result = frontend.find(query) #result = frontend.find(query)
@handle_pattern(r'^list (?P<field>[Aa]rtist)$') @handle_pattern(r'^list "?(?P<field>([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?'
@handle_pattern(r'^list "(?P<field>[Aa]rtist)"$') '( (?P<mpd_query>.*))?$')
@handle_pattern(r'^list (?P<field>album( artist)?)' def list_(frontend, field, mpd_query=None):
'( "(?P<artist>[^"]+)")*$')
@handle_pattern(r'^list "(?P<field>album(" "artist)?)"'
'( "(?P<artist>[^"]+)")*$')
def list_(frontend, field, artist=None):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -101,22 +98,70 @@ def list_(frontend, field, artist=None):
This filters the result list by an artist. This filters the result list by an artist.
*Clarifications:*
The musicpd.org documentation for ``list`` is far from complete. The
command also supports the following variant:
``list {TYPE} {QUERY}``
Where ``QUERY`` applies to all ``TYPE``. ``QUERY`` is one or more pairs
of a field name and a value. If the ``QUERY`` consists of more than one
pair, the pairs are AND-ed together to find the result. Examples of
valid queries and what they should return:
``list "artist" "artist" "ABBA"``
List artists where the artist name is "ABBA". Response::
Artist: ABBA
OK
``list "album" "artist" "ABBA"``
Lists albums where the artist name is "ABBA". Response::
Album: More ABBA Gold: More ABBA Hits
Album: Absolute More Christmas
Album: Gold: Greatest Hits
OK
``list "artist" "album" "Gold: Greatest Hits"``
Lists artists where the album name is "Gold: Greatest Hits".
Response::
Artist: ABBA
OK
``list "artist" "artist" "ABBA" "artist" "TLC"``
Lists artists where the artist name is "ABBA" *and* "TLC". Should
never match anything. Response::
OK
``list "date" "artist" "ABBA"``
Lists dates where artist name is "ABBA". Response::
Date:
Date: 1992
Date: 1993
OK
``list "date" "artist" "ABBA" "album" "Gold: Greatest Hits"``
Lists dates where artist name is "ABBA" and album name is "Gold:
Greatest Hits". Response::
Date: 1992
OK
``list "genre" "artist" "The Rolling Stones"``
Lists genres where artist name is "The Rolling Stones". Response::
Genre:
Genre: Rock
OK
*GMPC:* *GMPC:*
- does not add quotes around the field argument. - does not add quotes around the field argument.
- asks for "list artist" to get available artists and will not query
for artist/album information if this is not retrived
- asks for multiple fields, i.e.::
list album artist "an artist name"
returns the albums available for the asked artist::
list album artist "Tiesto"
Album: Radio Trance Vol 4-Promo-CD
Album: Ur A Tear in the Open CDR
Album: Simple Trance 2004 Step One
Album: In Concert 05-10-2003
*ncmpc:* *ncmpc:*
@ -124,31 +169,70 @@ def list_(frontend, field, artist=None):
- capitalizes the field argument. - capitalizes the field argument.
""" """
field = field.lower() field = field.lower()
query = _list_build_query(field, mpd_query)
if field == u'artist': if field == u'artist':
return _list_artist(frontend) return _list_artist(frontend, query)
elif field == u'album artist': elif field == u'album':
return _list_album_artist(frontend, artist) return _list_album(frontend, query)
# TODO More to implement elif field == u'date':
return _list_date(frontend, query)
elif field == u'genre':
pass # TODO We don't have genre in our internal data structures yet
def _list_artist(frontend): def _list_build_query(field, mpd_query):
""" """Converts a ``list`` query to a Mopidy query."""
Since we don't know exactly all available artists, we respond with if mpd_query is None:
the artists we know for sure, which is all artists in our stored playlists. return {}
""" # shlex does not seem to be friends with unicode objects
tokens = shlex.split(mpd_query.encode('utf-8'))
tokens = [t.decode('utf-8') for t in tokens]
if len(tokens) == 1:
if field == u'album':
return {'artist': [tokens[0]]}
else:
raise MpdArgError(
u'should be "Album" for 3 arguments', command=u'list')
elif len(tokens) % 2 == 0:
query = {}
while tokens:
key = tokens[0].lower()
key = str(key) # Needed for kwargs keys on OS X and Windows
value = tokens[1]
tokens = tokens[2:]
if key not in (u'artist', u'album', u'date', u'genre'):
raise MpdArgError(u'not able to parse args', command=u'list')
if key in query:
query[key].append(value)
else:
query[key] = [value]
return query
else:
raise MpdArgError(u'not able to parse args', command=u'list')
def _list_artist(frontend, query):
artists = set() artists = set()
for playlist in frontend.backend.stored_playlists.playlists: playlist = frontend.backend.library.find_exact(**query)
for track in playlist.tracks: for track in playlist.tracks:
for artist in track.artists: for artist in track.artists:
artists.add((u'Artist', artist.name)) artists.add((u'Artist', artist.name))
return artists return artists
def _list_album_artist(frontend, artist): def _list_album(frontend, query):
playlist = frontend.backend.library.find_exact(artist=[artist])
albums = set() albums = set()
playlist = frontend.backend.library.find_exact(**query)
for track in playlist.tracks: for track in playlist.tracks:
albums.add((u'Album', track.album.name)) if track.album is not None:
albums.add((u'Album', track.album.name))
return albums return albums
def _list_date(frontend, query):
dates = set()
playlist = frontend.backend.library.find_exact(**query)
for track in playlist.tracks:
if track.date is not None:
dates.add((u'Date', track.date.strftime('%Y-%m-%d')))
return dates
@handle_pattern(r'^listall "(?P<uri>[^"]+)"') @handle_pattern(r'^listall "(?P<uri>[^"]+)"')
def listall(frontend, uri): def listall(frontend, uri):
""" """

View File

@ -138,6 +138,10 @@ def playid(frontend, cpid):
at the first track. at the first track.
""" """
cpid = int(cpid) cpid = int(cpid)
paused = (frontend.backend.playback.state ==
frontend.backend.playback.PAUSED)
if cpid == -1 and paused:
return frontend.backend.playback.resume()
try: try:
if cpid == -1: if cpid == -1:
cp_track = _get_cp_track_for_play_minus_one(frontend) cp_track = _get_cp_track_for_play_minus_one(frontend)

View File

@ -24,6 +24,9 @@ class MpdServer(asyncore.dispatcher):
try: try:
if socket.has_ipv6: if socket.has_ipv6:
self.create_socket(socket.AF_INET6, socket.SOCK_STREAM) self.create_socket(socket.AF_INET6, socket.SOCK_STREAM)
# Explicitly configure socket to work for both IPv4 and IPv6
self.socket.setsockopt(
socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
else: else:
self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr() self.set_reuse_addr()

View File

@ -8,10 +8,8 @@ logger = logging.getLogger('mopidy.frontends.mpd.thread')
class MpdThread(BaseThread): class MpdThread(BaseThread):
def __init__(self, core_queue): def __init__(self, core_queue):
super(MpdThread, self).__init__() super(MpdThread, self).__init__(core_queue)
self.name = u'MpdThread' self.name = u'MpdThread'
self.daemon = True
self.core_queue = core_queue
def run_inside_try(self): def run_inside_try(self):
logger.debug(u'Starting MPD server thread') logger.debug(u'Starting MPD server thread')

View File

@ -4,7 +4,7 @@ from multiprocessing import Pipe
from mopidy import settings from mopidy import settings
from mopidy.mixers import BaseMixer from mopidy.mixers import BaseMixer
from mopidy.utils.process import BaseProcess from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.mixers.nad') logger = logging.getLogger('mopidy.mixers.nad')
@ -40,7 +40,7 @@ class NadMixer(BaseMixer):
super(NadMixer, self).__init__(*args, **kwargs) super(NadMixer, self).__init__(*args, **kwargs)
self._volume = None self._volume = None
self._pipe, other_end = Pipe() self._pipe, other_end = Pipe()
NadTalker(pipe=other_end).start() NadTalker(self.backend.core_queue, pipe=other_end).start()
def _get_volume(self): def _get_volume(self):
return self._volume return self._volume
@ -50,7 +50,7 @@ class NadMixer(BaseMixer):
self._pipe.send({'command': 'set_volume', 'volume': volume}) self._pipe.send({'command': 'set_volume', 'volume': volume})
class NadTalker(BaseProcess): class NadTalker(BaseThread):
""" """
Independent process which does the communication with the NAD device. Independent process which does the communication with the NAD device.
@ -72,8 +72,9 @@ class NadTalker(BaseProcess):
# Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration.
_nad_volume = None _nad_volume = None
def __init__(self, pipe=None): def __init__(self, core_queue, pipe=None):
super(NadTalker, self).__init__(name='NadTalker') super(NadTalker, self).__init__(core_queue)
self.name = u'NadTalker'
self.pipe = pipe self.pipe = pipe
self._device = None self._device = None

View File

@ -5,6 +5,9 @@ class DummyOutput(BaseOutput):
Audio output used for testing. Audio output used for testing.
""" """
# pylint: disable = R0902
# Too many instance attributes (9/7)
#: For testing. :class:`True` if :meth:`start` has been called. #: For testing. :class:`True` if :meth:`start` has been called.
start_called = False start_called = False

View File

@ -29,7 +29,7 @@ class GStreamerOutput(BaseOutput):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(GStreamerOutput, self).__init__(*args, **kwargs) super(GStreamerOutput, self).__init__(*args, **kwargs)
# Start a helper thread that can run the gobject.MainLoop # Start a helper thread that can run the gobject.MainLoop
self.messages_thread = GStreamerMessagesThread() self.messages_thread = GStreamerMessagesThread(self.core_queue)
# Start a helper thread that can process the output_queue # Start a helper thread that can process the output_queue
self.output_queue = multiprocessing.Queue() self.output_queue = multiprocessing.Queue()
@ -78,7 +78,8 @@ class GStreamerOutput(BaseOutput):
return self._send_recv({'command': 'get_position'}) return self._send_recv({'command': 'get_position'})
def set_position(self, position): def set_position(self, position):
return self._send_recv({'command': 'set_position', 'position': position}) return self._send_recv({'command': 'set_position',
'position': position})
def set_state(self, state): def set_state(self, state):
return self._send_recv({'command': 'set_state', 'state': state}) return self._send_recv({'command': 'set_state', 'state': state})
@ -91,10 +92,9 @@ class GStreamerOutput(BaseOutput):
class GStreamerMessagesThread(BaseThread): class GStreamerMessagesThread(BaseThread):
def __init__(self): def __init__(self, core_queue):
super(GStreamerMessagesThread, self).__init__() super(GStreamerMessagesThread, self).__init__(core_queue)
self.name = u'GStreamerMessagesThread' self.name = u'GStreamerMessagesThread'
self.daemon = True
def run_inside_try(self): def run_inside_try(self):
gobject.MainLoop().run() gobject.MainLoop().run()
@ -113,10 +113,8 @@ class GStreamerPlayerThread(BaseThread):
""" """
def __init__(self, core_queue, output_queue): def __init__(self, core_queue, output_queue):
super(GStreamerPlayerThread, self).__init__() super(GStreamerPlayerThread, self).__init__(core_queue)
self.name = u'GStreamerPlayerThread' self.name = u'GStreamerPlayerThread'
self.daemon = True
self.core_queue = core_queue
self.output_queue = output_queue self.output_queue = output_queue
self.gst_pipeline = None self.gst_pipeline = None
@ -142,7 +140,16 @@ class GStreamerPlayerThread(BaseThread):
uri_bin.connect('pad-added', self.process_new_pad, pad) uri_bin.connect('pad-added', self.process_new_pad, pad)
self.gst_pipeline.add(uri_bin) self.gst_pipeline.add(uri_bin)
else: else:
app_src = gst.element_factory_make('appsrc', 'src') app_src = gst.element_factory_make('appsrc', 'appsrc')
app_src_caps = gst.Caps("""
audio/x-raw-int,
endianness=(int)1234,
channels=(int)2,
width=(int)16,
depth=(int)16,
signed=(boolean)true,
rate=(int)44100""")
app_src.set_property('caps', app_src_caps)
self.gst_pipeline.add(app_src) self.gst_pipeline.add(app_src)
app_src.get_pad('src').link(pad) app_src.get_pad('src').link(pad)
@ -208,12 +215,12 @@ class GStreamerPlayerThread(BaseThread):
def deliver_data(self, caps_string, data): def deliver_data(self, caps_string, data):
"""Deliver audio data to be played""" """Deliver audio data to be played"""
data_src = self.gst_pipeline.get_by_name('src') app_src = self.gst_pipeline.get_by_name('appsrc')
caps = gst.caps_from_string(caps_string) caps = gst.caps_from_string(caps_string)
buffer_ = gst.Buffer(buffer(data)) buffer_ = gst.Buffer(buffer(data))
buffer_.set_caps(caps) buffer_.set_caps(caps)
data_src.set_property('caps', caps) app_src.set_property('caps', caps)
data_src.emit('push-buffer', buffer_) app_src.emit('push-buffer', buffer_)
def end_of_data_stream(self): def end_of_data_stream(self):
""" """
@ -222,7 +229,7 @@ class GStreamerPlayerThread(BaseThread):
We will get a GStreamer message when the stream playback reaches the We will get a GStreamer message when the stream playback reaches the
token, and can then do any end-of-stream related tasks. token, and can then do any end-of-stream related tasks.
""" """
self.gst_pipeline.get_by_name('src').emit('end-of-stream') self.gst_pipeline.get_by_name('appsrc').emit('end-of-stream')
def set_state(self, state_name): def set_state(self, state_name):
""" """

View File

@ -3,7 +3,6 @@ import multiprocessing
import multiprocessing.dummy import multiprocessing.dummy
from multiprocessing.reduction import reduce_connection from multiprocessing.reduction import reduce_connection
import pickle import pickle
import sys
from mopidy import SettingsError from mopidy import SettingsError
@ -17,24 +16,27 @@ def unpickle_connection(pickled_connection):
(func, args) = pickle.loads(pickled_connection) (func, args) = pickle.loads(pickled_connection)
return func(*args) return func(*args)
class BaseProcess(multiprocessing.Process): class BaseProcess(multiprocessing.Process):
def __init__(self, core_queue):
super(BaseProcess, self).__init__()
self.core_queue = core_queue
def run(self): def run(self):
logger.debug(u'%s: Starting process', self.name) logger.debug(u'%s: Starting process', self.name)
try: try:
self.run_inside_try() self.run_inside_try()
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info(u'%s: Interrupted by user', self.name) logger.info(u'Interrupted by user')
sys.exit(0) self.exit(0, u'Interrupted by user')
except SettingsError as e: except SettingsError as e:
logger.error(e.message) logger.error(e.message)
sys.exit(1) self.exit(1, u'Settings error')
except ImportError as e: except ImportError as e:
logger.error(e) logger.error(e)
sys.exit(1) self.exit(2, u'Import error')
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
raise e self.exit(3, u'Unknown error')
def run_inside_try(self): def run_inside_try(self):
raise NotImplementedError raise NotImplementedError
@ -42,27 +44,43 @@ class BaseProcess(multiprocessing.Process):
def destroy(self): def destroy(self):
self.terminate() self.terminate()
def exit(self, status=0, reason=None):
self.core_queue.put({'to': 'core', 'command': 'exit',
'status': status, 'reason': reason})
self.destroy()
class BaseThread(multiprocessing.dummy.Process): class BaseThread(multiprocessing.dummy.Process):
def __init__(self, core_queue):
super(BaseThread, self).__init__()
self.core_queue = core_queue
# No thread should block process from exiting
self.daemon = True
def run(self): def run(self):
logger.debug(u'%s: Starting process', self.name) logger.debug(u'%s: Starting thread', self.name)
try: try:
self.run_inside_try() self.run_inside_try()
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info(u'%s: Interrupted by user', self.name) logger.info(u'Interrupted by user')
sys.exit(0) self.exit(0, u'Interrupted by user')
except SettingsError as e: except SettingsError as e:
logger.error(e.message) logger.error(e.message)
sys.exit(1) self.exit(1, u'Settings error')
except ImportError as e: except ImportError as e:
logger.error(e) logger.error(e)
sys.exit(1) self.exit(2, u'Import error')
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
raise e self.exit(3, u'Unknown error')
def run_inside_try(self): def run_inside_try(self):
raise NotImplementedError raise NotImplementedError
def destroy(self): def destroy(self):
pass pass
def exit(self, status=0, reason=None):
self.core_queue.put({'to': 'core', 'command': 'exit',
'status': status, 'reason': reason})
self.destroy()

View File

@ -23,7 +23,9 @@ class SettingsProxy(object):
if not os.path.isfile(settings_file): if not os.path.isfile(settings_file):
return {} return {}
sys.path.insert(0, dotdir) sys.path.insert(0, dotdir)
# pylint: disable = F0401
import settings as local_settings_module import settings as local_settings_module
# pylint: enable = F0401
return self._get_settings_dict_from_module(local_settings_module) return self._get_settings_dict_from_module(local_settings_module)
def _get_settings_dict_from_module(self, module): def _get_settings_dict_from_module(self, module):

View File

@ -5,18 +5,19 @@
# #
# C0103 - Invalid name "%s" (should match %s) # C0103 - Invalid name "%s" (should match %s)
# C0111 - Missing docstring # C0111 - Missing docstring
# C0112 - Empty docstring
# E0102 - %s already defined line %s # E0102 - %s already defined line %s
# Does not understand @property getters and setters
# E0202 - An attribute inherited from %s hide this method # E0202 - An attribute inherited from %s hide this method
# Does not understand @property getters and setters
# E1101 - %s %r has no %r member # E1101 - %s %r has no %r member
# Does not understand @property getters and setters
# R0201 - Method could be a function # R0201 - Method could be a function
# R0801 - Similar lines in %s files # R0801 - Similar lines in %s files
# R0903 - Too few public methods (%s/%s) # R0903 - Too few public methods (%s/%s)
# R0904 - Too many public methods (%s/%s) # R0904 - Too many public methods (%s/%s)
# W0141 - Used builtin function %r # R0921 - Abstract class not referenced
# W0141 - Used builtin function '%s'
# W0142 - Used * or ** magic # W0142 - Used * or ** magic
# W0401 - Wildcard import %s
# W0511 - TODO, FIXME and XXX in the code
# W0613 - Unused argument %r # W0613 - Unused argument %r
# #
disable-msg = C0103,C0111,C0112,E0102,E0202,E1101,R0201,R0801,R0903,R0904,W0141,W0142,W0401,W0511,W0613 disable = C0103,C0111,E0102,E0202,E1101,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0613

View File

@ -128,7 +128,7 @@ class BaseCurrentPlaylistControllerTest(object):
def test_append_does_not_reset_version(self): def test_append_does_not_reset_version(self):
version = self.controller.version version = self.controller.version
self.controller.append([]) self.controller.append([])
self.assertEqual(self.controller.version, version + 1) self.assertEqual(self.controller.version, version)
@populate_playlist @populate_playlist
def test_append_preserves_playing_state(self): def test_append_preserves_playing_state(self):
@ -249,7 +249,12 @@ class BaseCurrentPlaylistControllerTest(object):
self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(self.tracks[0], shuffled_tracks[0])
self.assertEqual(set(self.tracks), set(shuffled_tracks)) self.assertEqual(set(self.tracks), set(shuffled_tracks))
def test_version(self): def test_version_does_not_change_when_appending_nothing(self):
version = self.controller.version version = self.controller.version
self.controller.append([]) self.controller.append([])
self.assertEquals(version, self.controller.version)
def test_version_increases_when_appending_something(self):
version = self.controller.version
self.controller.append([Track()])
self.assert_(version < self.controller.version) self.assert_(version < self.controller.version)

View File

@ -524,7 +524,7 @@ class BasePlaybackControllerTest(object):
wrapper.called = False wrapper.called = False
self.playback.on_current_playlist_change = wrapper self.playback.on_current_playlist_change = wrapper
self.backend.current_playlist.append([]) self.backend.current_playlist.append([Track()])
self.assert_(wrapper.called) self.assert_(wrapper.called)

View File

@ -135,7 +135,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
def test_deleteid(self): def test_deleteid(self):
self.b.current_playlist.append([Track(), Track()]) self.b.current_playlist.append([Track(), Track()])
self.assertEqual(len(self.b.current_playlist.tracks), 2) self.assertEqual(len(self.b.current_playlist.tracks), 2)
result = self.h.handle_request(u'deleteid "2"') result = self.h.handle_request(u'deleteid "1"')
self.assertEqual(len(self.b.current_playlist.tracks), 1) self.assertEqual(len(self.b.current_playlist.tracks), 1)
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
@ -193,7 +193,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), Track(name='d'), Track(name='e'), Track(name='f'),
]) ])
result = self.h.handle_request(u'moveid "5" "2"') result = self.h.handle_request(u'moveid "4" "2"')
self.assertEqual(self.b.current_playlist.tracks[0].name, 'a') self.assertEqual(self.b.current_playlist.tracks[0].name, 'a')
self.assertEqual(self.b.current_playlist.tracks[1].name, 'b') self.assertEqual(self.b.current_playlist.tracks[1].name, 'b')
self.assertEqual(self.b.current_playlist.tracks[2].name, 'e') self.assertEqual(self.b.current_playlist.tracks[2].name, 'e')
@ -229,7 +229,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
result = self.h.handle_request( result = self.h.handle_request(
u'playlistfind filename "file:///exists"') u'playlistfind filename "file:///exists"')
self.assert_(u'file: file:///exists' in result) self.assert_(u'file: file:///exists' in result)
self.assert_(u'Id: 1' in result) self.assert_(u'Id: 0' in result)
self.assert_(u'Pos: 0' in result) self.assert_(u'Pos: 0' in result)
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
@ -242,11 +242,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
def test_playlistid_with_songid(self): def test_playlistid_with_songid(self):
self.b.current_playlist.append([Track(name='a'), Track(name='b')]) self.b.current_playlist.append([Track(name='a'), Track(name='b')])
result = self.h.handle_request(u'playlistid "2"') result = self.h.handle_request(u'playlistid "1"')
self.assert_(u'Title: a' not in result) self.assert_(u'Title: a' not in result)
self.assert_(u'Id: 1' not in result) self.assert_(u'Id: 0' not in result)
self.assert_(u'Title: b' in result) self.assert_(u'Title: b' in result)
self.assert_(u'Id: 2' in result) self.assert_(u'Id: 1' in result)
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
def test_playlistid_with_not_existing_songid_fails(self): def test_playlistid_with_not_existing_songid_fails(self):
@ -429,7 +429,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), Track(name='d'), Track(name='e'), Track(name='f'),
]) ])
result = self.h.handle_request(u'swapid "2" "5"') result = self.h.handle_request(u'swapid "1" "4"')
self.assertEqual(self.b.current_playlist.tracks[0].name, 'a') self.assertEqual(self.b.current_playlist.tracks[0].name, 'a')
self.assertEqual(self.b.current_playlist.tracks[1].name, 'e') self.assertEqual(self.b.current_playlist.tracks[1].name, 'e')
self.assertEqual(self.b.current_playlist.tracks[2].name, 'c') self.assertEqual(self.b.current_playlist.tracks[2].name, 'c')

View File

@ -15,6 +15,59 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
self.assert_(u'playtime: 0' in result) self.assert_(u'playtime: 0' in result)
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
def test_findadd(self):
result = self.h.handle_request(u'findadd "album" "what"')
self.assert_(u'OK' in result)
def test_listall(self):
result = self.h.handle_request(u'listall "file:///dev/urandom"')
self.assert_(u'ACK [0@0] {} Not implemented' in result)
def test_listallinfo(self):
result = self.h.handle_request(u'listallinfo "file:///dev/urandom"')
self.assert_(u'ACK [0@0] {} Not implemented' in result)
def test_lsinfo_without_path_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo ""')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_lsinfo_for_root_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo "/"')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_update_without_uri(self):
result = self.h.handle_request(u'update')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_update_with_uri(self):
result = self.h.handle_request(u'update "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_without_uri(self):
result = self.h.handle_request(u'rescan')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_with_uri(self):
result = self.h.handle_request(u'rescan "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
class MusicDatabaseFindTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend(mixer_class=DummyMixer)
self.h = dispatcher.MpdDispatcher(backend=self.b)
def test_find_album(self): def test_find_album(self):
result = self.h.handle_request(u'find "album" "what"') result = self.h.handle_request(u'find "album" "what"')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
@ -48,11 +101,20 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
u'find album "album_what" artist "artist_what"') u'find album "album_what" artist "artist_what"')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
def test_findadd(self):
result = self.h.handle_request(u'findadd "album" "what"')
self.assert_(u'OK' in result)
def test_list_artist(self): class MusicDatabaseListTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend(mixer_class=DummyMixer)
self.h = dispatcher.MpdDispatcher(backend=self.b)
def test_list_foo_returns_ack(self):
result = self.h.handle_request(u'list "foo"')
self.assertEqual(result[0],
u'ACK [2@0] {list} incorrect arguments')
### Artist
def test_list_artist_with_quotes(self):
result = self.h.handle_request(u'list "artist"') result = self.h.handle_request(u'list "artist"')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
@ -64,44 +126,177 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
result = self.h.handle_request(u'list Artist') result = self.h.handle_request(u'list Artist')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
def test_list_artist_with_artist_should_fail(self): def test_list_artist_with_query_of_one_token(self):
result = self.h.handle_request(u'list "artist" "anartist"') result = self.h.handle_request(u'list "artist" "anartist"')
self.assertEqual(result[0], u'ACK [2@0] {list} incorrect arguments') self.assertEqual(result[0],
u'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_album_without_artist(self): def test_list_artist_with_unknown_field_in_query_returns_ack(self):
result = self.h.handle_request(u'list "artist" "foo" "bar"')
self.assertEqual(result[0],
u'ACK [2@0] {list} not able to parse args')
def test_list_artist_by_artist(self):
result = self.h.handle_request(u'list "artist" "artist" "anartist"')
self.assert_(u'OK' in result)
def test_list_artist_by_album(self):
result = self.h.handle_request(u'list "artist" "album" "analbum"')
self.assert_(u'OK' in result)
def test_list_artist_by_full_date(self):
result = self.h.handle_request(u'list "artist" "date" "2001-01-01"')
self.assert_(u'OK' in result)
def test_list_artist_by_year(self):
result = self.h.handle_request(u'list "artist" "date" "2001"')
self.assert_(u'OK' in result)
def test_list_artist_by_genre(self):
result = self.h.handle_request(u'list "artist" "genre" "agenre"')
self.assert_(u'OK' in result)
def test_list_artist_by_artist_and_album(self):
result = self.h.handle_request(
u'list "artist" "artist" "anartist" "album" "analbum"')
self.assert_(u'OK' in result)
### Album
def test_list_album_with_quotes(self):
result = self.h.handle_request(u'list "album"') result = self.h.handle_request(u'list "album"')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
def test_list_album_with_artist(self): def test_list_album_without_quotes(self):
result = self.h.handle_request(u'list album')
self.assert_(u'OK' in result)
def test_list_album_without_quotes_and_capitalized(self):
result = self.h.handle_request(u'list Album')
self.assert_(u'OK' in result)
def test_list_album_with_artist_name(self):
result = self.h.handle_request(u'list "album" "anartist"') result = self.h.handle_request(u'list "album" "anartist"')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
def test_list_album_artist_with_artist_without_quotes(self): def test_list_album_by_artist(self):
result = self.h.handle_request(u'list album artist "anartist"') result = self.h.handle_request(u'list "album" "artist" "anartist"')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
def test_listall(self): def test_list_album_by_album(self):
result = self.h.handle_request(u'listall "file:///dev/urandom"') result = self.h.handle_request(u'list "album" "album" "analbum"')
self.assert_(u'ACK [0@0] {} Not implemented' in result) self.assert_(u'OK' in result)
def test_listallinfo(self): def test_list_album_by_full_date(self):
result = self.h.handle_request(u'listallinfo "file:///dev/urandom"') result = self.h.handle_request(u'list "album" "date" "2001-01-01"')
self.assert_(u'ACK [0@0] {} Not implemented' in result) self.assert_(u'OK' in result)
def test_lsinfo_without_path_returns_same_as_listplaylists(self): def test_list_album_by_year(self):
lsinfo_result = self.h.handle_request(u'lsinfo') result = self.h.handle_request(u'list "album" "date" "2001"')
listplaylists_result = self.h.handle_request(u'listplaylists') self.assert_(u'OK' in result)
self.assertEqual(lsinfo_result, listplaylists_result)
def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): def test_list_album_by_genre(self):
lsinfo_result = self.h.handle_request(u'lsinfo ""') result = self.h.handle_request(u'list "album" "genre" "agenre"')
listplaylists_result = self.h.handle_request(u'listplaylists') self.assert_(u'OK' in result)
self.assertEqual(lsinfo_result, listplaylists_result)
def test_lsinfo_for_root_returns_same_as_listplaylists(self): def test_list_album_by_artist_and_album(self):
lsinfo_result = self.h.handle_request(u'lsinfo "/"') result = self.h.handle_request(
listplaylists_result = self.h.handle_request(u'listplaylists') u'list "album" "artist" "anartist" "album" "analbum"')
self.assertEqual(lsinfo_result, listplaylists_result) self.assert_(u'OK' in result)
### Date
def test_list_date_with_quotes(self):
result = self.h.handle_request(u'list "date"')
self.assert_(u'OK' in result)
def test_list_date_without_quotes(self):
result = self.h.handle_request(u'list date')
self.assert_(u'OK' in result)
def test_list_date_without_quotes_and_capitalized(self):
result = self.h.handle_request(u'list Date')
self.assert_(u'OK' in result)
def test_list_date_with_query_of_one_token(self):
result = self.h.handle_request(u'list "date" "anartist"')
self.assertEqual(result[0],
u'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_date_by_artist(self):
result = self.h.handle_request(u'list "date" "artist" "anartist"')
self.assert_(u'OK' in result)
def test_list_date_by_album(self):
result = self.h.handle_request(u'list "date" "album" "analbum"')
self.assert_(u'OK' in result)
def test_list_date_by_full_date(self):
result = self.h.handle_request(u'list "date" "date" "2001-01-01"')
self.assert_(u'OK' in result)
def test_list_date_by_year(self):
result = self.h.handle_request(u'list "date" "date" "2001"')
self.assert_(u'OK' in result)
def test_list_date_by_genre(self):
result = self.h.handle_request(u'list "date" "genre" "agenre"')
self.assert_(u'OK' in result)
def test_list_date_by_artist_and_album(self):
result = self.h.handle_request(
u'list "date" "artist" "anartist" "album" "analbum"')
self.assert_(u'OK' in result)
### Genre
def test_list_genre_with_quotes(self):
result = self.h.handle_request(u'list "genre"')
self.assert_(u'OK' in result)
def test_list_genre_without_quotes(self):
result = self.h.handle_request(u'list genre')
self.assert_(u'OK' in result)
def test_list_genre_without_quotes_and_capitalized(self):
result = self.h.handle_request(u'list Genre')
self.assert_(u'OK' in result)
def test_list_genre_with_query_of_one_token(self):
result = self.h.handle_request(u'list "genre" "anartist"')
self.assertEqual(result[0],
u'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_genre_by_artist(self):
result = self.h.handle_request(u'list "genre" "artist" "anartist"')
self.assert_(u'OK' in result)
def test_list_genre_by_album(self):
result = self.h.handle_request(u'list "genre" "album" "analbum"')
self.assert_(u'OK' in result)
def test_list_genre_by_full_date(self):
result = self.h.handle_request(u'list "genre" "date" "2001-01-01"')
self.assert_(u'OK' in result)
def test_list_genre_by_year(self):
result = self.h.handle_request(u'list "genre" "date" "2001"')
self.assert_(u'OK' in result)
def test_list_genre_by_genre(self):
result = self.h.handle_request(u'list "genre" "genre" "agenre"')
self.assert_(u'OK' in result)
def test_list_genre_by_artist_and_album(self):
result = self.h.handle_request(
u'list "genre" "artist" "anartist" "album" "analbum"')
self.assert_(u'OK' in result)
class MusicDatabaseSearchTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend(mixer_class=DummyMixer)
self.h = dispatcher.MpdDispatcher(backend=self.b)
def test_search_album(self): def test_search_album(self):
result = self.h.handle_request(u'search "album" "analbum"') result = self.h.handle_request(u'search "album" "analbum"')
@ -147,22 +342,4 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
result = self.h.handle_request(u'search "sometype" "something"') result = self.h.handle_request(u'search "sometype" "something"')
self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments') self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments')
def test_update_without_uri(self):
result = self.h.handle_request(u'update')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_update_with_uri(self):
result = self.h.handle_request(u'update "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_without_uri(self):
result = self.h.handle_request(u'rescan')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_with_uri(self):
result = self.h.handle_request(u'rescan "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)

View File

@ -254,7 +254,7 @@ class PlaybackControlHandlerTest(unittest.TestCase):
def test_playid(self): def test_playid(self):
self.b.current_playlist.append([Track()]) self.b.current_playlist.append([Track()])
result = self.h.handle_request(u'playid "1"') result = self.h.handle_request(u'playid "0"')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state)
@ -285,6 +285,18 @@ class PlaybackControlHandlerTest(unittest.TestCase):
self.assertEqual(self.b.playback.STOPPED, self.b.playback.state) self.assertEqual(self.b.playback.STOPPED, self.b.playback.state)
self.assertEqual(self.b.playback.current_track, None) self.assertEqual(self.b.playback.current_track, None)
def test_playid_minus_one_resumes_if_paused(self):
self.b.current_playlist.append([Track(length=40000)])
self.b.playback.seek(30000)
self.assert_(self.b.playback.time_position >= 30000)
self.assertEquals(self.b.playback.PLAYING, self.b.playback.state)
self.b.playback.pause()
self.assertEquals(self.b.playback.PAUSED, self.b.playback.state)
result = self.h.handle_request(u'playid "-1"')
self.assert_(u'OK' in result)
self.assertEqual(self.b.playback.PLAYING, self.b.playback.state)
self.assert_(self.b.playback.time_position >= 30000)
def test_playid_which_does_not_exist(self): def test_playid_which_does_not_exist(self):
self.b.current_playlist.append([Track()]) self.b.current_playlist.append([Track()])
result = self.h.handle_request(u'playid "12345"') result = self.h.handle_request(u'playid "12345"')
@ -310,7 +322,7 @@ class PlaybackControlHandlerTest(unittest.TestCase):
def test_seekid(self): def test_seekid(self):
self.b.current_playlist.append([Track(length=40000)]) self.b.current_playlist.append([Track(length=40000)])
result = self.h.handle_request(u'seekid "1" "30"') result = self.h.handle_request(u'seekid "0" "30"')
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
self.assert_(self.b.playback.time_position >= 30000) self.assert_(self.b.playback.time_position >= 30000)
@ -318,8 +330,8 @@ class PlaybackControlHandlerTest(unittest.TestCase):
seek_track = Track(uri='2', length=40000) seek_track = Track(uri='2', length=40000)
self.b.current_playlist.append( self.b.current_playlist.append(
[Track(length=40000), seek_track]) [Track(length=40000), seek_track])
result = self.h.handle_request(u'seekid "2" "30"') result = self.h.handle_request(u'seekid "1" "30"')
self.assertEqual(self.b.playback.current_cpid, 2) self.assertEqual(self.b.playback.current_cpid, 1)
self.assertEqual(self.b.playback.current_track, seek_track) self.assertEqual(self.b.playback.current_track, seek_track)
def test_stop(self): def test_stop(self):

View File

@ -0,0 +1,110 @@
import random
import unittest
from mopidy.backends.dummy import DummyBackend
from mopidy.frontends.mpd import dispatcher
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Track
class IssueGH17RegressionTest(unittest.TestCase):
"""
The issue: http://github.com/jodal/mopidy/issues#issue/17
How to reproduce:
- Play a playlist where one track cannot be played
- Turn on random mode
- Press next until you get to the unplayable track
"""
def setUp(self):
self.backend = DummyBackend(mixer_class=DummyMixer)
self.backend.current_playlist.append([
Track(uri='a'), Track(uri='b'), None,
Track(uri='d'), Track(uri='e'), Track(uri='f')])
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
def test(self):
random.seed(1) # Playlist order: abcfde
self.mpd.handle_request(u'play')
self.assertEquals('a', self.backend.playback.current_track.uri)
self.mpd.handle_request(u'random "1"')
self.mpd.handle_request(u'next')
self.assertEquals('b', self.backend.playback.current_track.uri)
self.mpd.handle_request(u'next')
# Should now be at track 'c', but playback fails and it skips ahead
self.assertEquals('f', self.backend.playback.current_track.uri)
self.mpd.handle_request(u'next')
self.assertEquals('d', self.backend.playback.current_track.uri)
self.mpd.handle_request(u'next')
self.assertEquals('e', self.backend.playback.current_track.uri)
class IssueGH18RegressionTest(unittest.TestCase):
"""
The issue: http://github.com/jodal/mopidy/issues#issue/18
How to reproduce:
Play, random on, next, random off, next, next.
At this point it gives the same song over and over.
"""
def setUp(self):
self.backend = DummyBackend(mixer_class=DummyMixer)
self.backend.current_playlist.append([
Track(uri='a'), Track(uri='b'), Track(uri='c'),
Track(uri='d'), Track(uri='e'), Track(uri='f')])
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
def test(self):
random.seed(1)
self.mpd.handle_request(u'play')
self.mpd.handle_request(u'random "1"')
self.mpd.handle_request(u'next')
self.mpd.handle_request(u'random "0"')
self.mpd.handle_request(u'next')
self.mpd.handle_request(u'next')
cp_track_1 = self.backend.playback.current_cp_track
self.mpd.handle_request(u'next')
cp_track_2 = self.backend.playback.current_cp_track
self.mpd.handle_request(u'next')
cp_track_3 = self.backend.playback.current_cp_track
self.assertNotEqual(cp_track_1, cp_track_2)
self.assertNotEqual(cp_track_2, cp_track_3)
class IssueGH22RegressionTest(unittest.TestCase):
"""
The issue: http://github.com/jodal/mopidy/issues/#issue/22
How to reproduce:
Play, random on, remove all tracks from the current playlist (as in
"delete" each one, not "clear").
Alternatively: Play, random on, remove a random track from the current
playlist, press next until it crashes.
"""
def setUp(self):
self.backend = DummyBackend(mixer_class=DummyMixer)
self.backend.current_playlist.append([
Track(uri='a'), Track(uri='b'), Track(uri='c'),
Track(uri='d'), Track(uri='e'), Track(uri='f')])
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
def test(self):
random.seed(1)
self.mpd.handle_request(u'play')
self.mpd.handle_request(u'random "1"')
self.mpd.handle_request(u'deleteid "1"')
self.mpd.handle_request(u'deleteid "2"')
self.mpd.handle_request(u'deleteid "3"')
self.mpd.handle_request(u'deleteid "4"')
self.mpd.handle_request(u'deleteid "5"')
self.mpd.handle_request(u'deleteid "6"')
self.mpd.handle_request(u'status')

View File

@ -27,7 +27,7 @@ class StatusHandlerTest(unittest.TestCase):
self.assert_(u'Track: 0' in result) self.assert_(u'Track: 0' in result)
self.assert_(u'Date: ' in result) self.assert_(u'Date: ' in result)
self.assert_(u'Pos: 0' in result) self.assert_(u'Pos: 0' in result)
self.assert_(u'Id: 1' in result) self.assert_(u'Id: 0' in result)
self.assert_(u'OK' in result) self.assert_(u'OK' in result)
def test_currentsong_without_song(self): def test_currentsong_without_song(self):
@ -166,7 +166,7 @@ class StatusHandlerTest(unittest.TestCase):
self.b.playback.play() self.b.playback.play()
result = dict(dispatcher.status.status(self.h)) result = dict(dispatcher.status.status(self.h))
self.assert_('songid' in result) self.assert_('songid' in result)
self.assertEqual(int(result['songid']), 1) self.assertEqual(int(result['songid']), 0)
def test_status_method_when_playing_contains_time_with_no_length(self): def test_status_method_when_playing_contains_time_with_no_length(self):
self.b.current_playlist.append([Track(length=None)]) self.b.current_playlist.append([Track(length=None)])

View File

@ -12,7 +12,7 @@ class VersionTest(unittest.TestCase):
self.assert_(SV('0.1.0a1') < SV('0.1.0a2')) self.assert_(SV('0.1.0a1') < SV('0.1.0a2'))
self.assert_(SV('0.1.0a2') < SV('0.1.0a3')) self.assert_(SV('0.1.0a2') < SV('0.1.0a3'))
self.assert_(SV('0.1.0a3') < SV('0.1.0')) self.assert_(SV('0.1.0a3') < SV('0.1.0'))
self.assert_(SV('0.1.0') < SV(get_version())) self.assert_(SV('0.1.0') < SV('0.2.0'))
self.assert_(SV(get_version()) < SV('0.2.0')) self.assert_(SV('0.1.0') < SV('1.0.0'))
self.assert_(SV('0.1.1') < SV('0.2.0')) self.assert_(SV('0.2.0') < SV(get_version()))
self.assert_(SV('0.2.0') < SV('1.0.0')) self.assert_(SV(get_version()) < SV('0.3.1'))