Merge pull request #654 from adamcik/feature/mpd-tokenized-requests

Tokenize MPD requests instead of regexp per command variant
This commit is contained in:
Stein Magnus Jodal 2014-02-16 23:23:23 +01:00
commit daa6af6f1b
25 changed files with 1210 additions and 779 deletions

View File

@ -8,7 +8,20 @@ This changelog is used to track all major changes to Mopidy.
v0.19.0 (UNRELEASED)
====================
- Nothing yet.
- Proper command tokenization for MPD requests. This replaces the old regex
based system with an MPD protocol specific tokenizer responsible for breaking
requests into pieces before the handlers have at them.
(Fixes: :issue:`591` and :issue:`592`)
- Updated commands handler system. As part of the tokenizer cleanup we've updated
how commands are registered and making it simpler to create new handlers.
- Simplifies a bunch of handlers. All the "browse" type commands now use a
common browse helper under the hood for less repetition. Likewise the query
handling of "search" commands has been somewhat simplified.
- Adds placeholders for missing MPD commands, preparing the way for bumping the
protocol version once they have been added.
v0.18.3 (2014-02-16)
@ -47,6 +60,8 @@ Bug fix release.
milliseconds since Unix epoch as an integer. This makes serialization of the
time stamp simpler.
**Windows**
=======
- Minor refactor of the MPD server context so that Mopidy's MPD protocol
implementation can easier be reused. (Fixes: :issue:`646`)

View File

@ -7,6 +7,13 @@ For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`.
.. automodule:: mopidy.mpd
:synopsis: MPD server frontend
MPD tokenizer
=============
.. automodule:: mopidy.mpd.tokenize
:synopsis: MPD request tokenizer
:members:
MPD dispatcher
==============

View File

@ -5,7 +5,7 @@ import re
import pykka
from mopidy.mpd import exceptions, protocol
from mopidy.mpd import exceptions, protocol, tokenize
logger = logging.getLogger(__name__)
@ -37,6 +37,7 @@ class MpdDispatcher(object):
response = []
filter_chain = [
self._catch_mpd_ack_errors_filter,
# TODO: tokenize filter
self._authenticate_filter,
self._command_list_filter,
self._idle_filter,
@ -88,10 +89,8 @@ class MpdDispatcher(object):
return self._call_next_filter(request, response, filter_chain)
else:
command_name = request.split(' ')[0]
command_names_not_requiring_auth = [
command.name for command in protocol.mpd_commands
if not command.auth_required]
if command_name in command_names_not_requiring_auth:
command = protocol.commands.handlers.get(command_name)
if command and not command.auth_required:
return self._call_next_filter(request, response, filter_chain)
else:
raise exceptions.MpdPermissionError(command=command_name)
@ -164,25 +163,15 @@ class MpdDispatcher(object):
raise exceptions.MpdSystemError(e)
def _call_handler(self, request):
(handler, kwargs) = self._find_handler(request)
tokens = tokenize.split(request)
try:
return handler(self.context, **kwargs)
return protocol.commands.call(tokens, context=self.context)
except exceptions.MpdAckError as exc:
if exc.command is None:
exc.command = handler.__name__.split('__', 1)[0]
exc.command = tokens[0]
raise
def _find_handler(self, request):
for pattern in protocol.request_handlers:
matches = re.match(pattern, request)
if matches is not None:
return (
protocol.request_handlers[pattern], matches.groupdict())
command_name = request.split(' ')[0]
if command_name in [command.name for command in protocol.mpd_commands]:
raise exceptions.MpdArgError(
'incorrect arguments', command=command_name)
raise exceptions.MpdUnknownCommand(command=command_name)
except LookupError:
raise exceptions.MpdUnknownCommand(command=tokens[0])
def _format_response(self, response):
formatted_response = []
@ -299,18 +288,35 @@ class MpdContext(object):
self.refresh_playlists_mapping()
return self._playlist_name_from_uri[uri]
# TODO: consider making context.browse(path) which uses this internally.
# advantage would be that all browse requests then go through the same code
# and we could prebuild/cache path->uri relationships instead of having to
# look them up all the time.
def directory_path_to_uri(self, path):
parts = re.findall(r'[^/]+', path)
def browse(self, path, recursive=True, lookup=True):
# TODO: consider caching lookups for less work mapping path->uri
path_parts = re.findall(r'[^/]+', path or '')
root_path = '/'.join([''] + path_parts)
uri = None
for part in parts:
for part in path_parts:
for ref in self.core.library.browse(uri).get():
if ref.type == ref.DIRECTORY and ref.name == part:
uri = ref.uri
break
else:
raise exceptions.MpdNoExistError()
return uri
raise exceptions.MpdNoExistError('Not found')
if recursive:
yield (root_path, None)
path_and_futures = [(root_path, self.core.library.browse(uri))]
while path_and_futures:
base_path, future = path_and_futures.pop()
for ref in future.get():
path = '/'.join([base_path, ref.name.replace('/', '')])
if ref.type == ref.DIRECTORY:
yield (path, None)
if recursive:
path_and_futures.append(
(path, self.core.library.browse(ref.uri)))
elif ref.type == ref.TRACK:
if lookup:
yield (path, self.core.library.lookup(ref.uri))
else:
yield (path, ref)

View File

@ -54,9 +54,11 @@ class MpdPermissionError(MpdAckError):
self.message = 'you don\'t have permission for "%s"' % self.command
class MpdUnknownCommand(MpdAckError):
class MpdUnknownError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_UNKNOWN
class MpdUnknownCommand(MpdUnknownError):
def __init__(self, *args, **kwargs):
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
assert self.command is not None, 'command must be given explicitly'

View File

@ -12,10 +12,9 @@ implement our own MPD server which is compatible with the numerous existing
from __future__ import unicode_literals
from collections import namedtuple
import re
import inspect
from mopidy.utils import formatting
from mopidy.mpd import exceptions
#: The MPD protocol uses UTF-8 for encoding all data.
ENCODING = 'UTF-8'
@ -26,70 +25,151 @@ LINE_TERMINATOR = '\n'
#: The MPD protocol version is 0.17.0.
VERSION = '0.17.0'
MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required'])
#: Set of all available commands, represented as :class:`MpdCommand` objects.
mpd_commands = set()
#: Map between request matchers and request handler functions.
request_handlers = {}
def handle_request(pattern, auth_required=True):
"""
Decorator for connecting command handlers to command requests.
If you use named groups in the pattern, the decorated method will get the
groups as keyword arguments. If the group is optional, remember to give the
argument a default value.
For example, if the command is ``do that thing`` the ``what`` argument will
be ``this thing``::
@handle_request('do\ (?P<what>.+)$')
def do(what):
...
Note that the patterns are compiled with the :attr:`re.VERBOSE` flag. Thus,
you must escape any space characters you want to match, but you're also
free to add non-escaped whitespace to format the pattern for easier
reading.
:param pattern: regexp pattern for matching commands
:type pattern: string
"""
def decorator(func):
match = re.search('([a-z_]+)', pattern)
if match is not None:
mpd_commands.add(
MpdCommand(name=match.group(), auth_required=auth_required))
compiled_pattern = re.compile(pattern, flags=(re.UNICODE | re.VERBOSE))
if compiled_pattern in request_handlers:
raise ValueError('Tried to redefine handler for %s with %s' % (
pattern, func))
request_handlers[compiled_pattern] = func
func.__doc__ = """
*Pattern:*
.. code-block:: text
%(pattern)s
%(docs)s
""" % {
'pattern': formatting.indent(pattern, places=8, singles=True),
'docs': func.__doc__ or '',
}
return func
return decorator
def load_protocol_modules():
"""
The protocol modules must be imported to get them registered in
:attr:`request_handlers` and :attr:`mpd_commands`.
:attr:`commands`.
"""
from . import ( # noqa
audio_output, channels, command_list, connection, current_playlist,
empty, music_db, playback, reflection, status, stickers,
stored_playlists)
music_db, playback, reflection, status, stickers, stored_playlists)
def INT(value):
"""Converts a value that matches [+-]?\d+ into and integer."""
if value is None:
raise ValueError('None is not a valid integer')
# TODO: check for whitespace via value != value.strip()?
return int(value)
def UINT(value):
"""Converts a value that matches \d+ into an integer."""
if value is None:
raise ValueError('None is not a valid integer')
if not value.isdigit():
raise ValueError('Only positive numbers are allowed')
return int(value)
def BOOL(value):
"""Convert the values 0 and 1 into booleans."""
if value in ('1', '0'):
return bool(int(value))
raise ValueError('%r is not 0 or 1' % value)
def RANGE(value):
"""Convert a single integer or range spec into a slice
``n`` should become ``slice(n, n+1)``
``n:`` should become ``slice(n, None)``
``n:m`` should become ``slice(n, m)`` and ``m > n`` must hold
"""
if ':' in value:
start, stop = value.split(':', 1)
start = UINT(start)
if stop.strip():
stop = UINT(stop)
if start >= stop:
raise ValueError('End must be larger than start')
else:
stop = None
else:
start = UINT(value)
stop = start + 1
return slice(start, stop)
class Commands(object):
"""Collection of MPD commands to expose to users.
Normally used through the global instance which command handlers have been
installed into.
"""
def __init__(self):
self.handlers = {}
# TODO: consider removing auth_required and list_command in favour of
# additional command instances to register in?
def add(self, name, auth_required=True, list_command=True, **validators):
"""Create a decorator that registers a handler and validation rules.
Additional keyword arguments are treated as converters/validators to
apply to tokens converting them to proper Python type`s.
Requirements for valid handlers:
- must accept a context argument as the first arg.
- may not use variable keyword arguments, ``**kwargs``.
- may use variable arguments ``*args`` *or* a mix of required and
optional arguments.
Decorator returns the unwrapped function so that tests etc can use the
functions with values with correct python types instead of strings.
:param string name: Name of the command being registered.
:param bool auth_required: If authorization is required.
:param bool list_command: If command should be listed in reflection.
"""
def wrapper(func):
if name in self.handlers:
raise ValueError('%s already registered' % name)
args, varargs, keywords, defaults = inspect.getargspec(func)
defaults = dict(zip(args[-len(defaults or []):], defaults or []))
if not args and not varargs:
raise TypeError('Handler must accept at least one argument.')
if len(args) > 1 and varargs:
raise TypeError(
'*args may not be combined with regular arguments')
if not set(validators.keys()).issubset(args):
raise TypeError('Validator for non-existent arg passed')
if keywords:
raise TypeError('**kwargs are not permitted')
def validate(*args, **kwargs):
if varargs:
return func(*args, **kwargs)
callargs = inspect.getcallargs(func, *args, **kwargs)
for key, value in callargs.items():
default = defaults.get(key, object())
if key in validators and value != default:
try:
callargs[key] = validators[key](value)
except ValueError:
raise exceptions.MpdArgError('incorrect arguments')
return func(**callargs)
validate.auth_required = auth_required
validate.list_command = list_command
self.handlers[name] = validate
return func
return wrapper
def call(self, tokens, context=None):
"""Find and run the handler registered for the given command.
If the handler was registered with any converters/validators they will
be run before calling the real handler.
:param list tokens: List of tokens to process
:param context: MPD context.
:type context: :class:`~mopidy.mpd.dispatcher.MpdContext`
"""
if not tokens:
raise exceptions.MpdNoCommand()
if tokens[0] not in self.handlers:
raise exceptions.MpdUnknownCommand(command=tokens[0])
return self.handlers[tokens[0]](context, *tokens[1:])
#: Global instance to install commands into
commands = Commands()

View File

@ -1,10 +1,9 @@
from __future__ import unicode_literals
from mopidy.mpd.exceptions import MpdNoExistError
from mopidy.mpd.protocol import handle_request
from mopidy.mpd import exceptions, protocol
@handle_request(r'disableoutput\ "(?P<outputid>\d+)"$')
@protocol.commands.add('disableoutput', outputid=protocol.UINT)
def disableoutput(context, outputid):
"""
*musicpd.org, audio output section:*
@ -13,13 +12,13 @@ def disableoutput(context, outputid):
Turns an output off.
"""
if int(outputid) == 0:
if outputid == 0:
context.core.playback.set_mute(False)
else:
raise MpdNoExistError('No such audio output')
raise exceptions.MpdNoExistError('No such audio output')
@handle_request(r'enableoutput\ "(?P<outputid>\d+)"$')
@protocol.commands.add('enableoutput', outputid=protocol.UINT)
def enableoutput(context, outputid):
"""
*musicpd.org, audio output section:*
@ -28,13 +27,26 @@ def enableoutput(context, outputid):
Turns an output on.
"""
if int(outputid) == 0:
if outputid == 0:
context.core.playback.set_mute(True)
else:
raise MpdNoExistError('No such audio output')
raise exceptions.MpdNoExistError('No such audio output')
@handle_request(r'outputs$')
# TODO: implement and test
#@protocol.commands.add('toggleoutput', outputid=protocol.UINT)
def toggleoutput(context, outputid):
"""
*musicpd.org, audio output section:*
``toggleoutput {ID}``
Turns an output on or off, depending on the current state.
"""
pass
@protocol.commands.add('outputs')
def outputs(context):
"""
*musicpd.org, audio output section:*

View File

@ -1,10 +1,9 @@
from __future__ import unicode_literals
from mopidy.mpd.protocol import handle_request
from mopidy.mpd.exceptions import MpdNotImplemented
from mopidy.mpd import exceptions, protocol
@handle_request(r'subscribe\ "(?P<channel>[A-Za-z0-9:._-]+)"$')
@protocol.commands.add('subscribe')
def subscribe(context, channel):
"""
*musicpd.org, client to client section:*
@ -15,10 +14,11 @@ def subscribe(context, channel):
already. The name may consist of alphanumeric ASCII characters plus
underscore, dash, dot and colon.
"""
raise MpdNotImplemented # TODO
# TODO: match channel against [A-Za-z0-9:._-]+
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'unsubscribe\ "(?P<channel>[A-Za-z0-9:._-]+)"$')
@protocol.commands.add('unsubscribe')
def unsubscribe(context, channel):
"""
*musicpd.org, client to client section:*
@ -27,10 +27,11 @@ def unsubscribe(context, channel):
Unsubscribe from a channel.
"""
raise MpdNotImplemented # TODO
# TODO: match channel against [A-Za-z0-9:._-]+
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'channels$')
@protocol.commands.add('channels')
def channels(context):
"""
*musicpd.org, client to client section:*
@ -40,10 +41,10 @@ def channels(context):
Obtain a list of all channels. The response is a list of "channel:"
lines.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'readmessages$')
@protocol.commands.add('readmessages')
def readmessages(context):
"""
*musicpd.org, client to client section:*
@ -53,11 +54,10 @@ def readmessages(context):
Reads messages for this client. The response is a list of "channel:"
and "message:" lines.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(
r'sendmessage\ "(?P<channel>[A-Za-z0-9:._-]+)"\ "(?P<text>[^"]*)"$')
@protocol.commands.add('sendmessage')
def sendmessage(context, channel, text):
"""
*musicpd.org, client to client section:*
@ -66,4 +66,5 @@ def sendmessage(context, channel, text):
Send a message to the specified channel.
"""
raise MpdNotImplemented # TODO
# TODO: match channel against [A-Za-z0-9:._-]+
raise exceptions.MpdNotImplemented # TODO

View File

@ -1,10 +1,9 @@
from __future__ import unicode_literals
from mopidy.mpd.protocol import handle_request
from mopidy.mpd.exceptions import MpdUnknownCommand
from mopidy.mpd import exceptions, protocol
@handle_request(r'command_list_begin$')
@protocol.commands.add('command_list_begin', list_command=False)
def command_list_begin(context):
"""
*musicpd.org, command list section:*
@ -26,11 +25,12 @@ def command_list_begin(context):
context.dispatcher.command_list = []
@handle_request(r'command_list_end$')
@protocol.commands.add('command_list_end', list_command=False)
def command_list_end(context):
"""See :meth:`command_list_begin()`."""
# TODO: batch consecutive add commands
if not context.dispatcher.command_list_receiving:
raise MpdUnknownCommand(command='command_list_end')
raise exceptions.MpdUnknownCommand(command='command_list_end')
context.dispatcher.command_list_receiving = False
(command_list, context.dispatcher.command_list) = (
context.dispatcher.command_list, [])
@ -49,7 +49,7 @@ def command_list_end(context):
return command_list_response
@handle_request(r'command_list_ok_begin$')
@protocol.commands.add('command_list_ok_begin', list_command=False)
def command_list_ok_begin(context):
"""See :meth:`command_list_begin()`."""
context.dispatcher.command_list_receiving = True

View File

@ -1,11 +1,9 @@
from __future__ import unicode_literals
from mopidy.mpd.protocol import handle_request
from mopidy.mpd.exceptions import (
MpdPasswordError, MpdPermissionError)
from mopidy.mpd import exceptions, protocol
@handle_request(r'close$', auth_required=False)
@protocol.commands.add('close', auth_required=False)
def close(context):
"""
*musicpd.org, connection section:*
@ -17,7 +15,7 @@ def close(context):
context.session.close()
@handle_request(r'kill$')
@protocol.commands.add('kill', list_command=False)
def kill(context):
"""
*musicpd.org, connection section:*
@ -26,10 +24,10 @@ def kill(context):
Kills MPD.
"""
raise MpdPermissionError(command='kill')
raise exceptions.MpdPermissionError(command='kill')
@handle_request(r'password\ "(?P<password>[^"]+)"$', auth_required=False)
@protocol.commands.add('password', auth_required=False)
def password(context, password):
"""
*musicpd.org, connection section:*
@ -42,10 +40,10 @@ def password(context, password):
if password == context.password:
context.dispatcher.authenticated = True
else:
raise MpdPasswordError('incorrect password')
raise exceptions.MpdPasswordError('incorrect password')
@handle_request(r'ping$', auth_required=False)
@protocol.commands.add('ping', auth_required=False)
def ping(context):
"""
*musicpd.org, connection section:*

View File

@ -1,12 +1,11 @@
from __future__ import unicode_literals
from mopidy.mpd import translator
from mopidy.mpd.exceptions import (
MpdArgError, MpdNoExistError, MpdNotImplemented)
from mopidy.mpd.protocol import handle_request
import warnings
from mopidy.mpd import exceptions, protocol, translator
@handle_request(r'add\ "(?P<uri>[^"]*)"$')
@protocol.commands.add('add')
def add(context, uri):
"""
*musicpd.org, current playlist section:*
@ -23,37 +22,24 @@ def add(context, uri):
if not uri.strip('/'):
return
tl_tracks = context.core.tracklist.add(uri=uri).get()
if tl_tracks:
if context.core.tracklist.add(uri=uri).get():
return
try:
uri = context.directory_path_to_uri(translator.normalize_path(uri))
except MpdNoExistError as e:
e.command = 'add'
tracks = []
for path, lookup_future in context.browse(uri):
if lookup_future:
tracks.extend(lookup_future.get())
except exceptions.MpdNoExistError as e:
e.message = 'directory or file not found'
raise
browse_futures = [context.core.library.browse(uri)]
lookup_futures = []
while browse_futures:
for ref in browse_futures.pop().get():
if ref.type == ref.DIRECTORY:
browse_futures.append(context.core.library.browse(ref.uri))
else:
lookup_futures.append(context.core.library.lookup(ref.uri))
tracks = []
for future in lookup_futures:
tracks.extend(future.get())
if not tracks:
raise MpdNoExistError('directory or file not found')
raise exceptions.MpdNoExistError('directory or file not found')
context.core.tracklist.add(tracks=tracks)
@handle_request(r'addid\ "(?P<uri>[^"]*)"(\ "(?P<songpos>\d+)")*$')
@protocol.commands.add('addid', songpos=protocol.UINT)
def addid(context, uri, songpos=None):
"""
*musicpd.org, current playlist section:*
@ -73,19 +59,17 @@ def addid(context, uri, songpos=None):
- ``addid ""`` should return an error.
"""
if not uri:
raise MpdNoExistError('No such song')
if songpos is not None:
songpos = int(songpos)
if songpos and songpos > context.core.tracklist.length.get():
raise MpdArgError('Bad song index')
raise exceptions.MpdNoExistError('No such song')
if songpos is not None and songpos > context.core.tracklist.length.get():
raise exceptions.MpdArgError('Bad song index')
tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get()
if not tl_tracks:
raise MpdNoExistError('No such song')
raise exceptions.MpdNoExistError('No such song')
return ('Id', tl_tracks[0].tlid)
@handle_request(r'delete\ "(?P<start>\d+):(?P<end>\d+)*"$')
def delete_range(context, start, end=None):
@protocol.commands.add('delete', position=protocol.RANGE)
def delete(context, position):
"""
*musicpd.org, current playlist section:*
@ -93,31 +77,18 @@ def delete_range(context, start, end=None):
Deletes a song from the playlist.
"""
start = int(start)
if end is not None:
end = int(end)
else:
start = position.start
end = position.stop
if end is None:
end = context.core.tracklist.length.get()
tl_tracks = context.core.tracklist.slice(start, end).get()
if not tl_tracks:
raise MpdArgError('Bad song index', command='delete')
raise exceptions.MpdArgError('Bad song index', command='delete')
for (tlid, _) in tl_tracks:
context.core.tracklist.remove(tlid=[tlid])
@handle_request(r'delete\ "(?P<songpos>\d+)"$')
def delete_songpos(context, songpos):
"""See :meth:`delete_range`"""
try:
songpos = int(songpos)
(tlid, _) = context.core.tracklist.slice(
songpos, songpos + 1).get()[0]
context.core.tracklist.remove(tlid=[tlid])
except IndexError:
raise MpdArgError('Bad song index', command='delete')
@handle_request(r'deleteid\ "(?P<tlid>\d+)"$')
@protocol.commands.add('deleteid', tlid=protocol.UINT)
def deleteid(context, tlid):
"""
*musicpd.org, current playlist section:*
@ -126,13 +97,12 @@ def deleteid(context, tlid):
Deletes the song ``SONGID`` from the playlist
"""
tlid = int(tlid)
tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get()
if not tl_tracks:
raise MpdNoExistError('No such song')
raise exceptions.MpdNoExistError('No such song')
@handle_request(r'clear$')
@protocol.commands.add('clear')
def clear(context):
"""
*musicpd.org, current playlist section:*
@ -144,8 +114,8 @@ def clear(context):
context.core.tracklist.clear()
@handle_request(r'move\ "(?P<start>\d+):(?P<end>\d+)*"\ "(?P<to>\d+)"$')
def move_range(context, start, to, end=None):
@protocol.commands.add('move', position=protocol.RANGE, to=protocol.UINT)
def move_range(context, position, to):
"""
*musicpd.org, current playlist section:*
@ -154,23 +124,14 @@ def move_range(context, start, to, end=None):
Moves the song at ``FROM`` or range of songs at ``START:END`` to
``TO`` in the playlist.
"""
start = position.start
end = position.stop
if end is None:
end = context.core.tracklist.length.get()
start = int(start)
end = int(end)
to = int(to)
context.core.tracklist.move(start, end, to)
@handle_request(r'move\ "(?P<songpos>\d+)"\ "(?P<to>\d+)"$')
def move_songpos(context, songpos, to):
"""See :meth:`move_range`."""
songpos = int(songpos)
to = int(to)
context.core.tracklist.move(songpos, songpos + 1, to)
@handle_request(r'moveid\ "(?P<tlid>\d+)"\ "(?P<to>\d+)"$')
@protocol.commands.add('moveid', tlid=protocol.UINT, to=protocol.UINT)
def moveid(context, tlid, to):
"""
*musicpd.org, current playlist section:*
@ -181,16 +142,14 @@ 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).
"""
tlid = int(tlid)
to = int(to)
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
if not tl_tracks:
raise MpdNoExistError('No such song')
raise exceptions.MpdNoExistError('No such song')
position = context.core.tracklist.index(tl_tracks[0]).get()
context.core.tracklist.move(position, position + 1, to)
@handle_request(r'playlist$')
@protocol.commands.add('playlist')
def playlist(context):
"""
*musicpd.org, current playlist section:*
@ -203,10 +162,12 @@ def playlist(context):
Do not use this, instead use ``playlistinfo``.
"""
warnings.warn(
'Do not use this, instead use playlistinfo', DeprecationWarning)
return playlistinfo(context)
@handle_request(r'playlistfind\ ("?)(?P<tag>[^"]+)\1\ "(?P<needle>[^"]+)"$')
@protocol.commands.add('playlistfind')
def playlistfind(context, tag, needle):
"""
*musicpd.org, current playlist section:*
@ -225,11 +186,10 @@ def playlistfind(context, tag, needle):
return None
position = context.core.tracklist.index(tl_tracks[0]).get()
return translator.track_to_mpd_format(tl_tracks[0], position=position)
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'playlistid$')
@handle_request(r'playlistid\ "(?P<tlid>\d+)"$')
@protocol.commands.add('playlistid', tlid=protocol.UINT)
def playlistid(context, tlid=None):
"""
*musicpd.org, current playlist section:*
@ -240,10 +200,9 @@ def playlistid(context, tlid=None):
and specifies a single song to display info for.
"""
if tlid is not None:
tlid = int(tlid)
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
if not tl_tracks:
raise MpdNoExistError('No such song')
raise exceptions.MpdNoExistError('No such song')
position = context.core.tracklist.index(tl_tracks[0]).get()
return translator.track_to_mpd_format(tl_tracks[0], position=position)
else:
@ -251,10 +210,8 @@ def playlistid(context, tlid=None):
context.core.tracklist.tl_tracks.get())
@handle_request(r'playlistinfo$')
@handle_request(r'playlistinfo\ "(?P<songpos>-?\d+)"$')
@handle_request(r'playlistinfo\ "(?P<start>\d+):(?P<end>\d+)*"$')
def playlistinfo(context, songpos=None, start=None, end=None):
@protocol.commands.add('playlistinfo')
def playlistinfo(context, parameter=None):
"""
*musicpd.org, current playlist section:*
@ -269,27 +226,21 @@ def playlistinfo(context, songpos=None, start=None, end=None):
- uses negative indexes, like ``playlistinfo "-1"``, to request
the entire playlist
"""
if songpos == '-1':
songpos = None
if songpos is not None:
songpos = int(songpos)
tl_track = context.core.tracklist.tl_tracks.get()[songpos]
return translator.track_to_mpd_format(tl_track, position=songpos)
if parameter is None or parameter == '-1':
start, end = 0, None
else:
if start is None:
start = 0
start = int(start)
if not (0 <= start <= context.core.tracklist.length.get()):
raise MpdArgError('Bad song index')
if end is not None:
end = int(end)
if end > context.core.tracklist.length.get():
end = None
tl_tracks = context.core.tracklist.tl_tracks.get()
return translator.tracks_to_mpd_format(tl_tracks, start, end)
tracklist_slice = protocol.RANGE(parameter)
start, end = tracklist_slice.start, tracklist_slice.stop
tl_tracks = context.core.tracklist.tl_tracks.get()
if start and start > len(tl_tracks):
raise exceptions.MpdArgError('Bad song index')
if end and end > len(tl_tracks):
end = None
return translator.tracks_to_mpd_format(tl_tracks, start, end)
@handle_request(r'playlistsearch\ ("?)(?P<tag>\w+)\1\ "(?P<needle>[^"]+)"$')
@protocol.commands.add('playlistsearch')
def playlistsearch(context, tag, needle):
"""
*musicpd.org, current playlist section:*
@ -304,10 +255,10 @@ def playlistsearch(context, tag, needle):
- does not add quotes around the tag
- uses ``filename`` and ``any`` as tags
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'plchanges\ ("?)(?P<version>-?\d+)\1$')
@protocol.commands.add('plchanges', version=protocol.INT)
def plchanges(context, version):
"""
*musicpd.org, current playlist section:*
@ -329,7 +280,7 @@ def plchanges(context, version):
context.core.tracklist.tl_tracks.get())
@handle_request(r'plchangesposid\ "(?P<version>\d+)"$')
@protocol.commands.add('plchangesposid', version=protocol.INT)
def plchangesposid(context, version):
"""
*musicpd.org, current playlist section:*
@ -353,9 +304,8 @@ def plchangesposid(context, version):
return result
@handle_request(r'shuffle$')
@handle_request(r'shuffle\ "(?P<start>\d+):(?P<end>\d+)*"$')
def shuffle(context, start=None, end=None):
@protocol.commands.add('shuffle', position=protocol.RANGE)
def shuffle(context, position=None):
"""
*musicpd.org, current playlist section:*
@ -364,14 +314,14 @@ def shuffle(context, start=None, end=None):
Shuffles the current playlist. ``START:END`` is optional and
specifies a range of songs.
"""
if start is not None:
start = int(start)
if end is not None:
end = int(end)
if position is None:
start, end = None, None
else:
start, end = position.start, position.stop
context.core.tracklist.shuffle(start, end)
@handle_request(r'swap\ "(?P<songpos1>\d+)"\ "(?P<songpos2>\d+)"$')
@protocol.commands.add('swap', songpos1=protocol.UINT, songpos2=protocol.UINT)
def swap(context, songpos1, songpos2):
"""
*musicpd.org, current playlist section:*
@ -380,8 +330,6 @@ def swap(context, songpos1, songpos2):
Swaps the positions of ``SONG1`` and ``SONG2``.
"""
songpos1 = int(songpos1)
songpos2 = int(songpos2)
tracks = context.core.tracklist.tracks.get()
song1 = tracks[songpos1]
song2 = tracks[songpos2]
@ -393,7 +341,7 @@ def swap(context, songpos1, songpos2):
context.core.tracklist.add(tracks)
@handle_request(r'swapid\ "(?P<tlid1>\d+)"\ "(?P<tlid2>\d+)"$')
@protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT)
def swapid(context, tlid1, tlid2):
"""
*musicpd.org, current playlist section:*
@ -402,12 +350,72 @@ def swapid(context, tlid1, tlid2):
Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids).
"""
tlid1 = int(tlid1)
tlid2 = int(tlid2)
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 MpdNoExistError('No such song')
raise exceptions.MpdNoExistError('No such song')
position1 = context.core.tracklist.index(tl_tracks1[0]).get()
position2 = context.core.tracklist.index(tl_tracks2[0]).get()
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):
"""
*musicpd.org, current playlist section:*
``addtagid {SONGID} {TAG} {VALUE}``
Adds a tag to the specified song. Editing song tags is only possible
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.
"""
pass
# TODO: add at least reflection tests before adding NotImplemented version
#@protocol.commands.add('cleartagid', tlid=protocol.UINT)
def cleartagid(context, tlid, tag):
"""
*musicpd.org, current playlist section:*
``cleartagid {SONGID} [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.
"""
pass

View File

@ -1,10 +0,0 @@
from __future__ import unicode_literals
from mopidy.mpd.protocol import handle_request
from mopidy.mpd.exceptions import MpdNoCommand
@handle_request(r'[\ ]*$')
def empty(context):
"""The original MPD server returns an error on an empty request."""
raise MpdNoCommand()

View File

@ -2,106 +2,46 @@ from __future__ import unicode_literals
import functools
import itertools
import re
from mopidy.models import Ref, Track
from mopidy.mpd import translator
from mopidy.mpd.exceptions import MpdArgError, MpdNoExistError
from mopidy.mpd.protocol import handle_request, stored_playlists
from mopidy.models import Track
from mopidy.mpd import exceptions, protocol, translator
_SEARCH_MAPPING = {
'album': 'album',
'albumartist': 'albumartist',
'any': 'any',
'artist': 'artist',
'comment': 'comment',
'composer': 'composer',
'date': 'date',
'file': 'uri',
'filename': 'uri',
'genre': 'genre',
'performer': 'performer',
'title': 'track_name',
'track': 'track_no'}
_LIST_MAPPING = {
'album': 'album',
'albumartist': 'albumartist',
'artist': 'artist',
'composer': 'composer',
'date': 'date',
'genre': 'genre',
'performer': 'performer'}
LIST_QUERY = r"""
("?) # Optional quote around the field type
(?P<field>( # Field to list in the response
[Aa]lbum
| [Aa]lbumartist
| [Aa]rtist
| [Cc]omposer
| [Dd]ate
| [Gg]enre
| [Pp]erformer
))
\1 # End of optional quote around the field type
(?: # Non-capturing group for optional search query
\ # A single space
(?P<mpd_query>.*)
)?
$
"""
SEARCH_FIELDS = r"""
[Aa]lbum
| [Aa]lbumartist
| [Aa]ny
| [Aa]rtist
| [Cc]omment
| [Cc]omposer
| [Dd]ate
| [Ff]ile
| [Ff]ilename
| [Gg]enre
| [Pp]erformer
| [Tt]itle
| [Tt]rack
"""
# TODO Would be nice to get ("?)...\1 working for the quotes here
SEARCH_QUERY = r"""
(?P<mpd_query>
(?: # Non-capturing group for repeating query pairs
"? # Optional quote around the field type
(?:
""" + SEARCH_FIELDS + r"""
)
"? # End of optional quote around the field type
\ # A single space
"[^"]*" # Matching a quoted search string
\s?
)+
)
$
"""
SEARCH_PAIR_WITH_GROUPS = r"""
("?) # Optional quote around the field type
\b # Only begin matching at word bundaries
( # A capturing group for the field type
""" + SEARCH_FIELDS + """
)
\\1 # End of optional quote around the field type
\ # A single space
"([^"]+)" # Capturing a quoted search string
"""
SEARCH_PAIR_WITH_GROUPS_RE = re.compile(
SEARCH_PAIR_WITH_GROUPS, flags=(re.UNICODE | re.VERBOSE))
def _query_from_mpd_search_format(mpd_query):
"""
Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy
query format.
:param mpd_query: the MPD search query
:type mpd_query: string
"""
matches = SEARCH_PAIR_WITH_GROUPS_RE.findall(mpd_query)
def _query_from_mpd_search_parameters(parameters, mapping):
query = {}
# discard first field, which just captures optional quote
for _, field, what in matches:
field = field.lower()
if field == 'title':
field = 'track_name'
elif field == 'track':
field = 'track_no'
elif field in ('file', 'filename'):
field = 'uri'
if not what:
parameters = list(parameters)
while parameters:
# TODO: does it matter that this is now case insensitive
field = mapping.get(parameters.pop(0).lower())
if not field:
raise exceptions.MpdArgError('incorrect arguments')
if not parameters:
raise ValueError
if field in query:
query[field].append(what)
else:
query[field] = [what]
query.setdefault(field, []).append(parameters.pop(0))
return query
@ -130,8 +70,8 @@ def _artist_as_track(artist):
artists=[artist])
@handle_request(r'count\ ' + SEARCH_QUERY)
def count(context, mpd_query):
@protocol.commands.add('count')
def count(context, *args):
"""
*musicpd.org, music database section:*
@ -146,9 +86,9 @@ def count(context, mpd_query):
- use multiple tag-needle pairs to make more specific searches.
"""
try:
query = _query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
raise MpdArgError('incorrect arguments')
raise exceptions.MpdArgError('incorrect arguments')
results = context.core.library.find_exact(**query).get()
result_tracks = _get_tracks(results)
return [
@ -157,8 +97,8 @@ def count(context, mpd_query):
]
@handle_request(r'find\ ' + SEARCH_QUERY)
def find(context, mpd_query):
@protocol.commands.add('find')
def find(context, *args):
"""
*musicpd.org, music database section:*
@ -186,9 +126,10 @@ def find(context, mpd_query):
- uses "file" instead of "filename".
"""
try:
query = _query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.find_exact(**query).get()
result_tracks = []
if ('artist' not in query and
@ -202,8 +143,8 @@ def find(context, mpd_query):
return translator.tracks_to_mpd_format(result_tracks)
@handle_request(r'findadd\ ' + SEARCH_QUERY)
def findadd(context, mpd_query):
@protocol.commands.add('findadd')
def findadd(context, *args):
"""
*musicpd.org, music database section:*
@ -213,15 +154,15 @@ def findadd(context, mpd_query):
current playlist. Parameters have the same meaning as for ``find``.
"""
try:
query = _query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.find_exact(**query).get()
context.core.tracklist.add(_get_tracks(results))
@handle_request(r'list\ ' + LIST_QUERY)
def list_(context, field, mpd_query=None):
@protocol.commands.add('list')
def list_(context, *args):
"""
*musicpd.org, music database section:*
@ -303,11 +244,27 @@ def list_(context, field, mpd_query=None):
- does not add quotes around the field argument.
- capitalizes the field argument.
"""
field = field.lower()
parameters = list(args)
if not parameters:
raise exceptions.MpdArgError('incorrect arguments')
field = parameters.pop(0).lower()
if field not in _LIST_MAPPING:
raise exceptions.MpdArgError('incorrect arguments')
if len(parameters) == 1:
if field != 'album':
raise exceptions.MpdArgError('should be "Album" for 3 arguments')
return _list_artist(context, {'artist': parameters})
try:
query = translator.query_from_mpd_list_format(field, mpd_query)
query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING)
except exceptions.MpdArgError as e:
e.message = 'not able to parse args'
raise
except ValueError:
return
if field == 'artist':
return _list_artist(context, query)
if field == 'albumartist':
@ -392,8 +349,7 @@ def _list_genre(context, query):
return genres
@handle_request(r'listall$')
@handle_request(r'listall\ "(?P<uri>[^"]+)"$')
@protocol.commands.add('listall')
def listall(context, uri=None):
"""
*musicpd.org, music database section:*
@ -403,36 +359,18 @@ def listall(context, uri=None):
Lists all songs and directories in ``URI``.
"""
result = []
root_path = translator.normalize_path(uri)
# TODO: doesn't the dispatcher._call_handler have enough info to catch
# the error this can produce, set the command and then 'raise'?
try:
uri = context.directory_path_to_uri(root_path)
except MpdNoExistError as e:
e.command = 'listall'
e.message = 'Not found'
raise
browse_futures = [(root_path, context.core.library.browse(uri))]
while browse_futures:
base_path, future = browse_futures.pop()
for ref in future.get():
if ref.type == Ref.DIRECTORY:
path = '/'.join([base_path, ref.name.replace('/', '')])
result.append(('directory', path))
browse_futures.append(
(path, context.core.library.browse(ref.uri)))
elif ref.type == Ref.TRACK:
result.append(('file', ref.uri))
for path, track_ref in context.browse(uri, lookup=False):
if not track_ref:
result.append(('directory', path))
else:
result.append(('file', track_ref.uri))
if not result:
raise MpdNoExistError('Not found')
return [('directory', root_path)] + result
raise exceptions.MpdNoExistError('Not found')
return result
@handle_request(r'listallinfo$')
@handle_request(r'listallinfo\ "(?P<uri>[^"]+)"$')
@protocol.commands.add('listallinfo')
def listallinfo(context, uri=None):
"""
*musicpd.org, music database section:*
@ -442,45 +380,17 @@ def listallinfo(context, uri=None):
Same as ``listall``, except it also returns metadata info in the
same format as ``lsinfo``.
"""
dirs_and_futures = []
result = []
root_path = translator.normalize_path(uri)
try:
uri = context.directory_path_to_uri(root_path)
except MpdNoExistError as e:
e.command = 'listallinfo'
e.message = 'Not found'
raise
browse_futures = [(root_path, context.core.library.browse(uri))]
while browse_futures:
base_path, future = browse_futures.pop()
for ref in future.get():
if ref.type == Ref.DIRECTORY:
path = '/'.join([base_path, ref.name.replace('/', '')])
future = context.core.library.browse(ref.uri)
browse_futures.append((path, future))
dirs_and_futures.append(('directory', path))
elif ref.type == Ref.TRACK:
# TODO Lookup tracks in batch for better performance
dirs_and_futures.append(context.core.library.lookup(ref.uri))
result = []
for obj in dirs_and_futures:
if hasattr(obj, 'get'):
for track in obj.get():
result.extend(translator.track_to_mpd_format(track))
for path, lookup_future in context.browse(uri):
if not lookup_future:
result.append(('directory', path))
else:
result.append(obj)
if not result:
raise MpdNoExistError('Not found')
return [('directory', root_path)] + result
for track in lookup_future.get():
result.extend(translator.track_to_mpd_format(track))
return result
@handle_request(r'lsinfo$')
@handle_request(r'lsinfo\ "(?P<uri>[^"]*)"$')
@protocol.commands.add('lsinfo')
def lsinfo(context, uri=None):
"""
*musicpd.org, music database section:*
@ -498,31 +408,23 @@ def lsinfo(context, uri=None):
""``, and ``lsinfo "/"``.
"""
result = []
root_path = translator.normalize_path(uri, relative=True)
try:
uri = context.directory_path_to_uri(root_path)
except MpdNoExistError as e:
e.command = 'lsinfo'
e.message = 'Not found'
raise
if uri in (None, '', '/'):
result.extend(protocol.stored_playlists.listplaylists(context))
if uri is None:
result.extend(stored_playlists.listplaylists(context))
for ref in context.core.library.browse(uri).get():
if ref.type == Ref.DIRECTORY:
path = '/'.join([root_path, ref.name.replace('/', '')])
for path, lookup_future in context.browse(uri, recursive=False):
if not lookup_future:
result.append(('directory', path.lstrip('/')))
elif ref.type == Ref.TRACK:
# TODO Lookup tracks in batch for better performance
tracks = context.core.library.lookup(ref.uri).get()
else:
tracks = lookup_future.get()
if tracks:
result.extend(translator.track_to_mpd_format(tracks[0]))
if not result:
raise exceptions.MpdNoExistError('Not found')
return result
@handle_request(r'rescan$')
@handle_request(r'rescan\ "(?P<uri>[^"]+)"$')
@protocol.commands.add('rescan')
def rescan(context, uri=None):
"""
*musicpd.org, music database section:*
@ -531,11 +433,11 @@ def rescan(context, uri=None):
Same as ``update``, but also rescans unmodified files.
"""
return update(context, uri, rescan_unmodified_files=True)
return {'updating_db': 0} # TODO
@handle_request(r'search\ ' + SEARCH_QUERY)
def search(context, mpd_query):
@protocol.commands.add('search')
def search(context, *args):
"""
*musicpd.org, music database section:*
@ -563,7 +465,7 @@ def search(context, mpd_query):
- uses "file" instead of "filename".
"""
try:
query = _query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.search(**query).get()
@ -573,8 +475,8 @@ def search(context, mpd_query):
return translator.tracks_to_mpd_format(artists + albums + tracks)
@handle_request(r'searchadd\ ' + SEARCH_QUERY)
def searchadd(context, mpd_query):
@protocol.commands.add('searchadd')
def searchadd(context, *args):
"""
*musicpd.org, music database section:*
@ -587,15 +489,15 @@ def searchadd(context, mpd_query):
not case sensitive.
"""
try:
query = _query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.search(**query).get()
context.core.tracklist.add(_get_tracks(results))
@handle_request(r'searchaddpl\ "(?P<playlist_name>[^"]+)"\ ' + SEARCH_QUERY)
def searchaddpl(context, playlist_name, mpd_query):
@protocol.commands.add('searchaddpl')
def searchaddpl(context, *args):
"""
*musicpd.org, music database section:*
@ -609,8 +511,12 @@ def searchaddpl(context, playlist_name, mpd_query):
Parameters have the same meaning as for ``find``, except that search is
not case sensitive.
"""
parameters = list(args)
if not parameters:
raise exceptions.MpdArgError('incorrect arguments')
playlist_name = parameters.pop(0)
try:
query = _query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.search(**query).get()
@ -623,9 +529,8 @@ def searchaddpl(context, playlist_name, mpd_query):
context.core.playlists.save(playlist)
@handle_request(r'update$')
@handle_request(r'update\ "(?P<uri>[^"]+)"$')
def update(context, uri=None, rescan_unmodified_files=False):
@protocol.commands.add('update')
def update(context, uri=None):
"""
*musicpd.org, music database section:*
@ -642,3 +547,27 @@ def update(context, uri=None, rescan_unmodified_files=False):
``status`` response.
"""
return {'updating_db': 0} # TODO
# TODO: add at least reflection tests before adding NotImplemented version
#@protocol.commands.add('readcomments')
def readcomments(context, uri):
"""
*musicpd.org, music database section:*
``readcomments [URI]``
Read "comments" (i.e. key-value pairs) from the file specified by
"URI". This "URI" can be a path relative to the music directory or a
URL in the form "file:///foo/bar.ogg".
This command may be used to list metadata of remote files (e.g. URI
beginning with "http://" or "smb://").
The response consists of lines in the form "KEY: VALUE". Comments with
suspicious characters (e.g. newlines) are ignored silently.
The meaning of these depends on the codec, and not all decoder plugins
support it. For example, on Ogg files, this lists the Vorbis comments.
"""
pass

View File

@ -1,12 +1,12 @@
from __future__ import unicode_literals
import warnings
from mopidy.core import PlaybackState
from mopidy.mpd.protocol import handle_request
from mopidy.mpd.exceptions import (
MpdArgError, MpdNoExistError, MpdNotImplemented)
from mopidy.mpd import exceptions, protocol
@handle_request(r'consume\ ("?)(?P<state>[01])\1$')
@protocol.commands.add('consume', state=protocol.BOOL)
def consume(context, state):
"""
*musicpd.org, playback section:*
@ -17,13 +17,10 @@ def consume(context, state):
1. When consume is activated, each song played is removed from
playlist.
"""
if int(state):
context.core.tracklist.consume = True
else:
context.core.tracklist.consume = False
context.core.tracklist.consume = state
@handle_request(r'crossfade\ "(?P<seconds>\d+)"$')
@protocol.commands.add('crossfade', seconds=protocol.UINT)
def crossfade(context, seconds):
"""
*musicpd.org, playback section:*
@ -32,11 +29,42 @@ def crossfade(context, seconds):
Sets crossfading between songs.
"""
seconds = int(seconds)
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'next$')
# TODO: add at least reflection tests before adding NotImplemented version
#@protocol.commands.add('mixrampdb')
def mixrampdb(context, decibels):
"""
*musicpd.org, playback section:*
``mixrampdb {deciBels}``
Sets the threshold at which songs will be overlapped. Like crossfading but
doesn't fade the track volume, just overlaps. The songs need to have
MixRamp tags added by an external tool. 0dB is the normalized maximum
volume so use negative values, I prefer -17dB. In the absence of mixramp
tags crossfading will be used. See http://sourceforge.net/projects/mixramp
"""
pass
# TODO: add at least reflection tests before adding NotImplemented version
#@protocol.commands.add('mixrampdelay', seconds=protocol.UINT)
def mixrampdelay(context, seconds):
"""
*musicpd.org, playback section:*
``mixrampdelay {SECONDS}``
Additional time subtracted from the overlap calculated by mixrampdb. A
value of "nan" disables MixRamp overlapping and falls back to
crossfading.
"""
pass
@protocol.commands.add('next')
def next_(context):
"""
*musicpd.org, playback section:*
@ -94,8 +122,7 @@ def next_(context):
return context.core.playback.next().get()
@handle_request(r'pause$')
@handle_request(r'pause\ "(?P<state>[01])"$')
@protocol.commands.add('pause', state=protocol.BOOL)
def pause(context, state=None):
"""
*musicpd.org, playback section:*
@ -109,54 +136,22 @@ def pause(context, state=None):
- Calls ``pause`` without any arguments to toogle pause.
"""
if state is None:
warnings.warn(
'The use of pause command w/o the PAUSE argument is deprecated.',
DeprecationWarning)
if (context.core.playback.state.get() == PlaybackState.PLAYING):
context.core.playback.pause()
elif (context.core.playback.state.get() == PlaybackState.PAUSED):
context.core.playback.resume()
elif int(state):
elif state:
context.core.playback.pause()
else:
context.core.playback.resume()
@handle_request(r'play$')
def play(context):
"""
The original MPD server resumes from the paused state on ``play``
without arguments.
"""
return context.core.playback.play().get()
@handle_request(r'playid\ ("?)(?P<tlid>-?\d+)\1$')
def playid(context, tlid):
"""
*musicpd.org, playback section:*
``playid [SONGID]``
Begins playing the playlist at song ``SONGID``.
*Clarifications:*
- ``playid "-1"`` when playing is ignored.
- ``playid "-1"`` when paused resumes playback.
- ``playid "-1"`` when stopped with a current track starts playback at the
current track.
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
replacement, starts playback at the first track.
"""
tlid = int(tlid)
if tlid == -1:
return _play_minus_one(context)
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
if not tl_tracks:
raise MpdNoExistError('No such song')
return context.core.playback.play(tl_tracks[0]).get()
@handle_request(r'play\ ("?)(?P<songpos>-?\d+)\1$')
def play__pos(context, songpos):
@protocol.commands.add('play', tlid=protocol.INT)
def play(context, tlid=None):
"""
*musicpd.org, playback section:*
@ -164,6 +159,9 @@ def play__pos(context, songpos):
Begins playing the playlist at song number ``SONGPOS``.
The original MPD server resumes from the paused state on ``play``
without arguments.
*Clarifications:*
- ``play "-1"`` when playing is ignored.
@ -177,14 +175,16 @@ def play__pos(context, songpos):
- issues ``play 6`` without quotes around the argument.
"""
songpos = int(songpos)
if songpos == -1:
if tlid is None:
return context.core.playback.play().get()
elif tlid == -1:
return _play_minus_one(context)
try:
tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0]
tl_track = context.core.tracklist.slice(tlid, tlid + 1).get()[0]
return context.core.playback.play(tl_track).get()
except IndexError:
raise MpdArgError('Bad song index')
raise exceptions.MpdArgError('Bad song index')
def _play_minus_one(context):
@ -202,7 +202,33 @@ def _play_minus_one(context):
return # Fail silently
@handle_request(r'previous$')
@protocol.commands.add('playid', tlid=protocol.INT)
def playid(context, tlid):
"""
*musicpd.org, playback section:*
``playid [SONGID]``
Begins playing the playlist at song ``SONGID``.
*Clarifications:*
- ``playid "-1"`` when playing is ignored.
- ``playid "-1"`` when paused resumes playback.
- ``playid "-1"`` when stopped with a current track starts playback at the
current track.
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
replacement, starts playback at the first track.
"""
if tlid == -1:
return _play_minus_one(context)
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()
@protocol.commands.add('previous')
def previous(context):
"""
*musicpd.org, playback section:*
@ -249,7 +275,7 @@ def previous(context):
return context.core.playback.previous().get()
@handle_request(r'random\ ("?)(?P<state>[01])\1$')
@protocol.commands.add('random', state=protocol.BOOL)
def random(context, state):
"""
*musicpd.org, playback section:*
@ -258,13 +284,10 @@ def random(context, state):
Sets random state to ``STATE``, ``STATE`` should be 0 or 1.
"""
if int(state):
context.core.tracklist.random = True
else:
context.core.tracklist.random = False
context.core.tracklist.random = state
@handle_request(r'repeat\ ("?)(?P<state>[01])\1$')
@protocol.commands.add('repeat', state=protocol.BOOL)
def repeat(context, state):
"""
*musicpd.org, playback section:*
@ -273,13 +296,10 @@ def repeat(context, state):
Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1.
"""
if int(state):
context.core.tracklist.repeat = True
else:
context.core.tracklist.repeat = False
context.core.tracklist.repeat = state
@handle_request(r'replay_gain_mode\ "(?P<mode>(off|track|album))"$')
@protocol.commands.add('replay_gain_mode')
def replay_gain_mode(context, mode):
"""
*musicpd.org, playback section:*
@ -293,10 +313,10 @@ def replay_gain_mode(context, mode):
This command triggers the options idle event.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'replay_gain_status$')
@protocol.commands.add('replay_gain_status')
def replay_gain_status(context):
"""
*musicpd.org, playback section:*
@ -309,8 +329,8 @@ def replay_gain_status(context):
return 'off' # TODO
@handle_request(r'seek\ ("?)(?P<songpos>\d+)\1\ ("?)(?P<seconds>\d+)\3$')
def seek(context, songpos, seconds):
@protocol.commands.add('seek', tlid=protocol.UINT, seconds=protocol.UINT)
def seek(context, tlid, seconds):
"""
*musicpd.org, playback section:*
@ -324,12 +344,12 @@ def seek(context, songpos, 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() != int(songpos):
play__pos(context, songpos)
context.core.playback.seek(int(seconds) * 1000).get()
if context.core.tracklist.index(tl_track).get() != tlid:
play(context, tlid)
context.core.playback.seek(seconds * 1000).get()
@handle_request(r'seekid\ "(?P<tlid>\d+)"\ "(?P<seconds>\d+)"$')
@protocol.commands.add('seekid', tlid=protocol.UINT, seconds=protocol.UINT)
def seekid(context, tlid, seconds):
"""
*musicpd.org, playback section:*
@ -339,14 +359,13 @@ def seekid(context, tlid, seconds):
Seeks to the position ``TIME`` (in seconds) of song ``SONGID``.
"""
tl_track = context.core.playback.current_tl_track.get()
if not tl_track or tl_track.tlid != int(tlid):
if not tl_track or tl_track.tlid != tlid:
playid(context, tlid)
context.core.playback.seek(int(seconds) * 1000).get()
context.core.playback.seek(seconds * 1000).get()
@handle_request(r'seekcur\ "(?P<position>\d+)"$')
@handle_request(r'seekcur\ "(?P<diff>[-+]\d+)"$')
def seekcur(context, position=None, diff=None):
@protocol.commands.add('seekcur')
def seekcur(context, time):
"""
*musicpd.org, playback section:*
@ -355,16 +374,16 @@ def seekcur(context, position=None, diff=None):
Seeks to the position ``TIME`` within the current song. If prefixed by
'+' or '-', then the time is relative to the current playing position.
"""
if position is not None:
position = int(position) * 1000
context.core.playback.seek(position).get()
elif diff is not None:
if time.startswith(('+', '-')):
position = context.core.playback.time_position.get()
position += int(diff) * 1000
position += protocol.INT(time) * 1000
context.core.playback.seek(position).get()
else:
position = protocol.UINT(time) * 1000
context.core.playback.seek(position).get()
@handle_request(r'setvol\ ("?)(?P<volume>[-+]*\d+)\1$')
@protocol.commands.add('setvol', volume=protocol.INT)
def setvol(context, volume):
"""
*musicpd.org, playback section:*
@ -377,15 +396,11 @@ def setvol(context, volume):
- issues ``setvol 50`` without quotes around the argument.
"""
volume = int(volume)
if volume < 0:
volume = 0
if volume > 100:
volume = 100
context.core.playback.volume = volume
# NOTE: we use INT as clients can pass in +N etc.
context.core.playback.volume = min(max(0, volume), 100)
@handle_request(r'single\ ("?)(?P<state>[01])\1$')
@protocol.commands.add('single', state=protocol.BOOL)
def single(context, state):
"""
*musicpd.org, playback section:*
@ -396,13 +411,10 @@ def single(context, state):
single is activated, playback is stopped after current song, or
song is repeated if the ``repeat`` mode is enabled.
"""
if int(state):
context.core.tracklist.single = True
else:
context.core.tracklist.single = False
context.core.tracklist.single = state
@handle_request(r'stop$')
@protocol.commands.add('stop')
def stop(context):
"""
*musicpd.org, playback section:*

View File

@ -1,10 +1,9 @@
from __future__ import unicode_literals
from mopidy.mpd.exceptions import MpdPermissionError
from mopidy.mpd.protocol import handle_request, mpd_commands
from mopidy.mpd import exceptions, protocol
@handle_request(r'config$', auth_required=False)
@protocol.commands.add('config', list_command=False)
def config(context):
"""
*musicpd.org, reflection section:*
@ -15,10 +14,10 @@ def config(context):
command is only permitted to "local" clients (connected via UNIX domain
socket).
"""
raise MpdPermissionError(command='config')
raise exceptions.MpdPermissionError(command='config')
@handle_request(r'commands$', auth_required=False)
@protocol.commands.add('commands', auth_required=False)
def commands(context):
"""
*musicpd.org, reflection section:*
@ -27,25 +26,18 @@ def commands(context):
Shows which commands the current user has access to.
"""
if context.dispatcher.authenticated:
command_names = set([command.name for command in mpd_commands])
else:
command_names = set([
command.name for command in mpd_commands
if not command.auth_required])
# No one is permited to use 'config' or 'kill', rest of commands are not
# listed by MPD, so we shouldn't either.
command_names = command_names - set([
'config', 'kill', 'command_list_begin', 'command_list_ok_begin',
'command_list_ok_begin', 'command_list_end', 'idle', 'noidle',
'sticker'])
command_names = set()
for name, handler in protocol.commands.handlers.items():
if not handler.list_command:
continue
if context.dispatcher.authenticated or not handler.auth_required:
command_names.add(name)
return [
('command', command_name) for command_name in sorted(command_names)]
@handle_request(r'decoders$')
@protocol.commands.add('decoders')
def decoders(context):
"""
*musicpd.org, reflection section:*
@ -72,7 +64,7 @@ def decoders(context):
return # TODO
@handle_request(r'notcommands$', auth_required=False)
@protocol.commands.add('notcommands', auth_required=False)
def notcommands(context):
"""
*musicpd.org, reflection section:*
@ -81,21 +73,18 @@ def notcommands(context):
Shows which commands the current user does not have access to.
"""
if context.dispatcher.authenticated:
command_names = []
else:
command_names = [
command.name for command in mpd_commands if command.auth_required]
# No permission to use
command_names.append('config')
command_names.append('kill')
command_names = set(['config', 'kill']) # No permission to use
for name, handler in protocol.commands.handlers.items():
if not handler.list_command:
continue
if not context.dispatcher.authenticated and handler.auth_required:
command_names.add(name)
return [
('command', command_name) for command_name in sorted(command_names)]
@handle_request(r'tagtypes$')
@protocol.commands.add('tagtypes')
def tagtypes(context):
"""
*musicpd.org, reflection section:*
@ -107,7 +96,7 @@ def tagtypes(context):
pass # TODO
@handle_request(r'urlhandlers$')
@protocol.commands.add('urlhandlers')
def urlhandlers(context):
"""
*musicpd.org, reflection section:*

View File

@ -3,9 +3,7 @@ from __future__ import unicode_literals
import pykka
from mopidy.core import PlaybackState
from mopidy.mpd.exceptions import MpdNotImplemented
from mopidy.mpd.protocol import handle_request
from mopidy.mpd.translator import track_to_mpd_format
from mopidy.mpd import exceptions, protocol, translator
#: Subsystems that can be registered with idle command.
SUBSYSTEMS = [
@ -13,7 +11,7 @@ SUBSYSTEMS = [
'stored_playlist', 'update']
@handle_request(r'clearerror$')
@protocol.commands.add('clearerror')
def clearerror(context):
"""
*musicpd.org, status section:*
@ -23,10 +21,10 @@ def clearerror(context):
Clears the current error message in status (this is also
accomplished by any command that starts playback).
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'currentsong$')
@protocol.commands.add('currentsong')
def currentsong(context):
"""
*musicpd.org, status section:*
@ -39,12 +37,11 @@ def currentsong(context):
tl_track = context.core.playback.current_tl_track.get()
if tl_track is not None:
position = context.core.tracklist.index(tl_track).get()
return track_to_mpd_format(tl_track, position=position)
return translator.track_to_mpd_format(tl_track, position=position)
@handle_request(r'idle$')
@handle_request(r'idle\ (?P<subsystems>.+)$')
def idle(context, subsystems=None):
@protocol.commands.add('idle', list_command=False)
def idle(context, *subsystems):
"""
*musicpd.org, status section:*
@ -77,10 +74,9 @@ def idle(context, subsystems=None):
notifications when something changed in one of the specified
subsystems.
"""
# TODO: test against valid subsystems
if subsystems:
subsystems = subsystems.split()
else:
if not subsystems:
subsystems = SUBSYSTEMS
for subsystem in subsystems:
@ -100,7 +96,7 @@ def idle(context, subsystems=None):
return response
@handle_request(r'noidle$')
@protocol.commands.add('noidle', list_command=False)
def noidle(context):
"""See :meth:`_status_idle`."""
if not context.subscriptions:
@ -110,7 +106,7 @@ def noidle(context):
context.session.prevent_timeout = False
@handle_request(r'stats$')
@protocol.commands.add('stats')
def stats(context):
"""
*musicpd.org, status section:*
@ -137,7 +133,7 @@ def stats(context):
}
@handle_request(r'status$')
@protocol.commands.add('status')
def status(context):
"""
*musicpd.org, status section:*

View File

@ -1,76 +1,38 @@
from __future__ import unicode_literals
from mopidy.mpd.protocol import handle_request
from mopidy.mpd.exceptions import MpdNotImplemented
from mopidy.mpd import exceptions, protocol
@handle_request(
r'sticker\ delete\ "(?P<field>[^"]+)"\ '
r'"(?P<uri>[^"]+)"(\ "(?P<name>[^"]+)")*$')
def sticker__delete(context, field, uri, name=None):
"""
*musicpd.org, sticker section:*
``sticker delete {TYPE} {URI} [NAME]``
Deletes a sticker value from the specified object. If you do not
specify a sticker name, all sticker values are deleted.
"""
raise MpdNotImplemented # TODO
@handle_request(
r'sticker\ find\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)"$')
def sticker__find(context, field, uri, name):
"""
*musicpd.org, sticker section:*
``sticker find {TYPE} {URI} {NAME}``
Searches the sticker database for stickers with the specified name,
below the specified directory (``URI``). For each matching song, it
prints the ``URI`` and that one sticker's value.
"""
raise MpdNotImplemented # TODO
@handle_request(
r'sticker\ get\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)"$')
def sticker__get(context, field, uri, name):
"""
*musicpd.org, sticker section:*
``sticker get {TYPE} {URI} {NAME}``
Reads a sticker value for the specified object.
"""
raise MpdNotImplemented # TODO
@handle_request(r'sticker\ list\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"$')
def sticker__list(context, field, uri):
@protocol.commands.add('sticker', list_command=False)
def sticker(context, action, field, uri, name=None, value=None):
"""
*musicpd.org, sticker section:*
``sticker list {TYPE} {URI}``
Lists the stickers for the specified object.
"""
raise MpdNotImplemented # TODO
``sticker find {TYPE} {URI} {NAME}``
@handle_request(
r'sticker\ set\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)"\ "(?P<value>[^"]+)"$')
def sticker__set(context, field, uri, name, value):
"""
*musicpd.org, sticker section:*
Searches the sticker database for stickers with the specified name,
below the specified directory (``URI``). For each matching song, it
prints the ``URI`` and that one sticker's value.
``sticker get {TYPE} {URI} {NAME}``
Reads a sticker value for the specified object.
``sticker set {TYPE} {URI} {NAME} {VALUE}``
Adds a sticker value to the specified object. If a sticker item
with that name already exists, it is replaced.
``sticker delete {TYPE} {URI} [NAME]``
Deletes a sticker value from the specified object. If you do not
specify a sticker name, all sticker values are deleted.
"""
raise MpdNotImplemented # TODO
# TODO: check that action in ('list', 'find', 'get', 'set', 'delete')
# TODO: check name/value matches with action
raise exceptions.MpdNotImplemented # TODO

View File

@ -2,12 +2,10 @@ from __future__ import division, unicode_literals
import datetime
from mopidy.mpd.exceptions import MpdNoExistError, MpdNotImplemented
from mopidy.mpd.protocol import handle_request
from mopidy.mpd.translator import playlist_to_mpd_format
from mopidy.mpd import exceptions, protocol, translator
@handle_request(r'listplaylist\ ("?)(?P<name>[^"]+)\1$')
@protocol.commands.add('listplaylist')
def listplaylist(context, name):
"""
*musicpd.org, stored playlists section:*
@ -24,11 +22,11 @@ def listplaylist(context, name):
"""
playlist = context.lookup_playlist_from_name(name)
if not playlist:
raise MpdNoExistError('No such playlist')
raise exceptions.MpdNoExistError('No such playlist')
return ['file: %s' % t.uri for t in playlist.tracks]
@handle_request(r'listplaylistinfo\ ("?)(?P<name>[^"]+)\1$')
@protocol.commands.add('listplaylistinfo')
def listplaylistinfo(context, name):
"""
*musicpd.org, stored playlists section:*
@ -44,11 +42,11 @@ def listplaylistinfo(context, name):
"""
playlist = context.lookup_playlist_from_name(name)
if not playlist:
raise MpdNoExistError('No such playlist')
return playlist_to_mpd_format(playlist)
raise exceptions.MpdNoExistError('No such playlist')
return translator.playlist_to_mpd_format(playlist)
@handle_request(r'listplaylists$')
@protocol.commands.add('listplaylists')
def listplaylists(context):
"""
*musicpd.org, stored playlists section:*
@ -84,6 +82,7 @@ def listplaylists(context):
return result
# TODO: move to translators?
def _get_last_modified(playlist):
"""Formats last modified timestamp of a playlist for MPD.
@ -100,9 +99,8 @@ def _get_last_modified(playlist):
return '%sZ' % dt.isoformat()
@handle_request(
r'load\ "(?P<name>[^"]+)"(\ "(?P<start>\d+):(?P<end>\d+)*")*$')
def load(context, name, start=None, end=None):
@protocol.commands.add('load', playlist_slice=protocol.RANGE)
def load(context, name, playlist_slice=slice(0, None)):
"""
*musicpd.org, stored playlists section:*
@ -125,15 +123,11 @@ def load(context, name, start=None, end=None):
"""
playlist = context.lookup_playlist_from_name(name)
if not playlist:
raise MpdNoExistError('No such playlist')
if start is not None:
start = int(start)
if end is not None:
end = int(end)
context.core.tracklist.add(playlist.tracks[start:end])
raise exceptions.MpdNoExistError('No such playlist')
context.core.tracklist.add(playlist.tracks[playlist_slice])
@handle_request(r'playlistadd\ "(?P<name>[^"]+)"\ "(?P<uri>[^"]+)"$')
@protocol.commands.add('playlistadd')
def playlistadd(context, name, uri):
"""
*musicpd.org, stored playlists section:*
@ -144,10 +138,10 @@ def playlistadd(context, name, uri):
``NAME.m3u`` will be created if it does not exist.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'playlistclear\ "(?P<name>[^"]+)"$')
@protocol.commands.add('playlistclear')
def playlistclear(context, name):
"""
*musicpd.org, stored playlists section:*
@ -156,10 +150,10 @@ def playlistclear(context, name):
Clears the playlist ``NAME.m3u``.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'playlistdelete\ "(?P<name>[^"]+)"\ "(?P<songpos>\d+)"$')
@protocol.commands.add('playlistdelete', songpos=protocol.UINT)
def playlistdelete(context, name, songpos):
"""
*musicpd.org, stored playlists section:*
@ -168,12 +162,11 @@ def playlistdelete(context, name, songpos):
Deletes ``SONGPOS`` from the playlist ``NAME.m3u``.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(
r'playlistmove\ "(?P<name>[^"]+)"\ '
r'"(?P<from_pos>\d+)"\ "(?P<to_pos>\d+)"$')
@protocol.commands.add(
'playlistmove', from_pos=protocol.UINT, to_pos=protocol.UINT)
def playlistmove(context, name, from_pos, to_pos):
"""
*musicpd.org, stored playlists section:*
@ -189,10 +182,10 @@ def playlistmove(context, name, from_pos, to_pos):
documentation, but just the ``SONGPOS`` to move *from*, i.e.
``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'rename\ "(?P<old_name>[^"]+)"\ "(?P<new_name>[^"]+)"$')
@protocol.commands.add('rename')
def rename(context, old_name, new_name):
"""
*musicpd.org, stored playlists section:*
@ -201,10 +194,10 @@ def rename(context, old_name, new_name):
Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'rm\ "(?P<name>[^"]+)"$')
@protocol.commands.add('rm')
def rm(context, name):
"""
*musicpd.org, stored playlists section:*
@ -213,10 +206,10 @@ def rm(context, name):
Removes the playlist ``NAME.m3u`` from the playlist directory.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO
@handle_request(r'save\ "(?P<name>[^"]+)"$')
@protocol.commands.add('save')
def save(context, name):
"""
*musicpd.org, stored playlists section:*
@ -226,4 +219,4 @@ def save(context, name):
Saves the current playlist to ``NAME.m3u`` in the playlist
directory.
"""
raise MpdNotImplemented # TODO
raise exceptions.MpdNotImplemented # TODO

View File

@ -45,7 +45,7 @@ class MpdSession(network.LineProtocol):
def decode(self, line):
try:
return super(MpdSession, self).decode(line.decode('string_escape'))
return super(MpdSession, self).decode(line)
except ValueError:
logger.warning(
'Stopping actor due to unescaping error, data '

88
mopidy/mpd/tokenize.py Normal file
View File

@ -0,0 +1,88 @@
from __future__ import unicode_literals
import re
from mopidy.mpd import exceptions
WORD_RE = re.compile(r"""
^
(\s*) # Leading whitespace not allowed, capture it to report.
([a-z][a-z0-9_]*) # A command name
(?:\s+|$) # trailing whitespace or EOS
(.*) # Possibly a remainder to be parsed
""", re.VERBOSE)
# Quotes matching is an unrolled version of "(?:[^"\\]|\\.)*"
PARAM_RE = re.compile(r"""
^ # Leading whitespace is not allowed
(?:
([^%(unprintable)s"']+) # ord(char) < 0x20, not ", not '
| # or
"([^"\\]*(?:\\.[^"\\]*)*)" # anything surrounded by quotes
)
(?:\s+|$) # trailing whitespace or EOS
(.*) # Possibly a remainder to be parsed
""" % {'unprintable': ''.join(map(chr, range(0x21)))}, re.VERBOSE)
BAD_QUOTED_PARAM_RE = re.compile(r"""
^
"[^"\\]*(?:\\.[^"\\]*)* # start of a quoted value
(?: # followed by:
("[^\s]) # non-escaped quote, followed by non-whitespace
| # or
([^"]) # anything that is not a quote
)
""", re.VERBOSE)
UNESCAPE_RE = re.compile(r'\\(.)') # Backslash escapes any following char.
def split(line):
"""Splits a line into tokens using same rules as MPD.
- Lines may not start with whitespace
- Tokens are split by arbitrary amount of spaces or tabs
- First token must match `[a-z][a-z0-9_]*`
- Remaining tokens can be unquoted or quoted tokens.
- Unquoted tokens consist of all printable characters except double quotes,
single quotes, spaces and tabs.
- Quoted tokens are surrounded by a matching pair of double quotes.
- The closing quote must be followed by space, tab or end of line.
- Any value is allowed inside a quoted token. Including double quotes,
assuming it is correctly escaped.
- Backslash inside a quoted token is used to escape the following
character.
For examples see the tests for this function.
"""
if not line.strip():
raise exceptions.MpdNoCommand('No command given')
match = WORD_RE.match(line)
if not match:
raise exceptions.MpdUnknownError('Invalid word character')
whitespace, command, remainder = match.groups()
if whitespace:
raise exceptions.MpdUnknownError('Letter expected')
result = [command]
while remainder:
match = PARAM_RE.match(remainder)
if not match:
msg = _determine_error_message(remainder)
raise exceptions.MpdArgError(msg, command=command)
unquoted, quoted, remainder = match.groups()
result.append(unquoted or UNESCAPE_RE.sub(r'\g<1>', quoted))
return result
def _determine_error_message(remainder):
"""Helper to emulate MPD errors."""
# Following checks are simply to match MPD error messages:
match = BAD_QUOTED_PARAM_RE.match(remainder)
if match:
if match.group(1):
return 'Space expected after closing \'"\''
else:
return 'Missing closing \'"\''
return 'Invalid unquoted character'

View File

@ -1,9 +1,7 @@
from __future__ import unicode_literals
import re
import shlex
from mopidy.mpd.exceptions import MpdArgError
from mopidy.models import TlTrack
# TODO: special handling of local:// uri scheme
@ -137,46 +135,3 @@ def playlist_to_mpd_format(playlist, *args, **kwargs):
Arguments as for :func:`tracks_to_mpd_format`, except the first one.
"""
return tracks_to_mpd_format(playlist.tracks, *args, **kwargs)
def query_from_mpd_list_format(field, mpd_query):
"""
Converts an MPD ``list`` query to a Mopidy query.
"""
if mpd_query is None:
return {}
try:
# shlex does not seem to be friends with unicode objects
tokens = shlex.split(mpd_query.encode('utf-8'))
except ValueError as error:
if str(error) == 'No closing quotation':
raise MpdArgError('Invalid unquoted character', command='list')
else:
raise
tokens = [t.decode('utf-8') for t in tokens]
if len(tokens) == 1:
if field == 'album':
if not tokens[0]:
raise ValueError
return {'artist': [tokens[0]]}
else:
raise MpdArgError(
'should be "Album" for 3 arguments', command='list')
elif len(tokens) % 2 == 0:
query = {}
while tokens:
key = tokens[0].lower()
value = tokens[1]
tokens = tokens[2:]
if key not in ('artist', 'album', 'albumartist', 'composer',
'date', 'genre', 'performer'):
raise MpdArgError('not able to parse args', command='list')
if not value:
raise ValueError
if key in query:
query[key].append(value)
else:
query[key] = [value]
return query
else:
raise MpdArgError('not able to parse args', command='list')

View File

@ -389,6 +389,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
self.sendRequest('playlistinfo "0:20"')
self.assertInResponse('OK')
def test_playlistinfo_with_zero_returns_ok(self):
self.sendRequest('playlistinfo "0"')
self.assertInResponse('OK')
def test_playlistsearch(self):
self.sendRequest('playlistsearch "any" "needle"')
self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented')

View File

@ -10,8 +10,8 @@ from tests.mpd import protocol
class QueryFromMpdSearchFormatTest(unittest.TestCase):
def test_dates_are_extracted(self):
result = music_db._query_from_mpd_search_format(
'Date "1974-01-02" "Date" "1975"')
result = music_db._query_from_mpd_search_parameters(
['Date', '1974-01-02', 'Date', '1975'], music_db._SEARCH_MAPPING)
self.assertEqual(result['date'][0], '1974-01-02')
self.assertEqual(result['date'][1], '1975')
@ -305,12 +305,33 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase):
def test_lsinfo_for_dir_includes_subdirs(self):
self.backend.library.dummy_browse_result = {
'dummy:/': [Ref.directory(uri='/foo', name='foo')]}
'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')]}
self.sendRequest('lsinfo "/dummy"')
self.assertInResponse('directory: dummy/foo')
self.assertInResponse('OK')
def test_lsinfo_for_dir_does_not_recurse(self):
self.backend.library.dummy_library = [
Track(uri='dummy:/a', name='a'),
]
self.backend.library.dummy_browse_result = {
'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')],
'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]}
self.sendRequest('lsinfo "/dummy"')
self.assertNotInResponse('file: dummy:/a')
self.assertInResponse('OK')
def test_lsinfo_for_dir_does_not_include_self(self):
self.backend.library.dummy_browse_result = {
'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')],
'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]}
self.sendRequest('lsinfo "/dummy"')
self.assertNotInResponse('directory: dummy')
self.assertInResponse('OK')
def test_update_without_uri(self):
self.sendRequest('update')
self.assertInResponse('updating_db: 0')

241
tests/mpd/test_commands.py Normal file
View File

@ -0,0 +1,241 @@
#encoding: utf-8
from __future__ import unicode_literals
import unittest
from mopidy.mpd import exceptions, protocol
class TestConverts(unittest.TestCase):
def test_integer(self):
self.assertEqual(123, protocol.INT('123'))
self.assertEqual(-123, protocol.INT('-123'))
self.assertEqual(123, protocol.INT('+123'))
self.assertRaises(ValueError, protocol.INT, '3.14')
self.assertRaises(ValueError, protocol.INT, '')
self.assertRaises(ValueError, protocol.INT, 'abc')
self.assertRaises(ValueError, protocol.INT, '12 34')
def test_unsigned_integer(self):
self.assertEqual(123, protocol.UINT('123'))
self.assertRaises(ValueError, protocol.UINT, '-123')
self.assertRaises(ValueError, protocol.UINT, '+123')
self.assertRaises(ValueError, protocol.UINT, '3.14')
self.assertRaises(ValueError, protocol.UINT, '')
self.assertRaises(ValueError, protocol.UINT, 'abc')
self.assertRaises(ValueError, protocol.UINT, '12 34')
def test_boolean(self):
self.assertEqual(True, protocol.BOOL('1'))
self.assertEqual(False, protocol.BOOL('0'))
self.assertRaises(ValueError, protocol.BOOL, '3.14')
self.assertRaises(ValueError, protocol.BOOL, '')
self.assertRaises(ValueError, protocol.BOOL, 'true')
self.assertRaises(ValueError, protocol.BOOL, 'false')
self.assertRaises(ValueError, protocol.BOOL, 'abc')
self.assertRaises(ValueError, protocol.BOOL, '12 34')
def test_range(self):
self.assertEqual(slice(1, 2), protocol.RANGE('1'))
self.assertEqual(slice(0, 1), protocol.RANGE('0'))
self.assertEqual(slice(0, None), protocol.RANGE('0:'))
self.assertEqual(slice(1, 3), protocol.RANGE('1:3'))
self.assertRaises(ValueError, protocol.RANGE, '3.14')
self.assertRaises(ValueError, protocol.RANGE, '1:abc')
self.assertRaises(ValueError, protocol.RANGE, 'abc:1')
self.assertRaises(ValueError, protocol.RANGE, '2:1')
self.assertRaises(ValueError, protocol.RANGE, '-1:2')
self.assertRaises(ValueError, protocol.RANGE, '1 : 2')
self.assertRaises(ValueError, protocol.RANGE, '')
self.assertRaises(ValueError, protocol.RANGE, 'true')
self.assertRaises(ValueError, protocol.RANGE, 'false')
self.assertRaises(ValueError, protocol.RANGE, 'abc')
self.assertRaises(ValueError, protocol.RANGE, '12 34')
class TestCommands(unittest.TestCase):
def setUp(self):
self.commands = protocol.Commands()
def test_add_as_a_decorator(self):
@self.commands.add('test')
def test(context):
pass
def test_register_second_command_to_same_name_fails(self):
func = lambda context: True
self.commands.add('foo')(func)
with self.assertRaises(Exception):
self.commands.add('foo')(func)
def test_function_only_takes_context_succeeds(self):
sentinel = object()
self.commands.add('bar')(lambda context: sentinel)
self.assertEqual(sentinel, self.commands.call(['bar']))
def test_function_has_required_arg_succeeds(self):
sentinel = object()
self.commands.add('bar')(lambda context, required: sentinel)
self.assertEqual(sentinel, self.commands.call(['bar', 'arg']))
def test_function_has_optional_args_succeeds(self):
sentinel = object()
self.commands.add('bar')(lambda context, optional=None: sentinel)
self.assertEqual(sentinel, self.commands.call(['bar']))
self.assertEqual(sentinel, self.commands.call(['bar', 'arg']))
def test_function_has_required_and_optional_args_succeeds(self):
sentinel = object()
func = lambda context, required, optional=None: sentinel
self.commands.add('bar')(func)
self.assertEqual(sentinel, self.commands.call(['bar', 'arg']))
self.assertEqual(sentinel, self.commands.call(['bar', 'arg', 'arg']))
def test_function_has_varargs_succeeds(self):
sentinel, args = object(), []
self.commands.add('bar')(lambda context, *args: sentinel)
for i in range(10):
self.assertEqual(sentinel, self.commands.call(['bar'] + args))
args.append('test')
def test_function_has_only_varags_succeeds(self):
sentinel = object()
self.commands.add('baz')(lambda *args: sentinel)
self.assertEqual(sentinel, self.commands.call(['baz']))
def test_function_has_no_arguments_fails(self):
with self.assertRaises(TypeError):
self.commands.add('test')(lambda: True)
def test_function_has_required_and_varargs_fails(self):
with self.assertRaises(TypeError):
func = lambda context, required, *args: True
self.commands.add('test')(func)
def test_function_has_optional_and_varargs_fails(self):
with self.assertRaises(TypeError):
func = lambda context, optional=None, *args: True
self.commands.add('test')(func)
def test_function_hash_keywordargs_fails(self):
with self.assertRaises(TypeError):
self.commands.add('test')(lambda context, **kwargs: True)
def test_call_chooses_correct_handler(self):
sentinel1, sentinel2, sentinel3 = object(), object(), object()
self.commands.add('foo')(lambda context: sentinel1)
self.commands.add('bar')(lambda context: sentinel2)
self.commands.add('baz')(lambda context: sentinel3)
self.assertEqual(sentinel1, self.commands.call(['foo']))
self.assertEqual(sentinel2, self.commands.call(['bar']))
self.assertEqual(sentinel3, self.commands.call(['baz']))
def test_call_with_nonexistent_handler(self):
with self.assertRaises(exceptions.MpdUnknownCommand):
self.commands.call(['bar'])
def test_call_passes_context(self):
sentinel = object()
self.commands.add('foo')(lambda context: context)
self.assertEqual(
sentinel, self.commands.call(['foo'], context=sentinel))
def test_call_without_args_fails(self):
with self.assertRaises(exceptions.MpdNoCommand):
self.commands.call([])
def test_call_passes_required_argument(self):
self.commands.add('foo')(lambda context, required: required)
self.assertEqual('test123', self.commands.call(['foo', 'test123']))
def test_call_passes_optional_argument(self):
sentinel = object()
self.commands.add('foo')(lambda context, optional=sentinel: optional)
self.assertEqual(sentinel, self.commands.call(['foo']))
self.assertEqual('test', self.commands.call(['foo', 'test']))
def test_call_passes_required_and_optional_argument(self):
func = lambda context, required, optional=None: (required, optional)
self.commands.add('foo')(func)
self.assertEqual(('arg', None), self.commands.call(['foo', 'arg']))
self.assertEqual(
('arg', 'kwarg'), self.commands.call(['foo', 'arg', 'kwarg']))
def test_call_passes_varargs(self):
self.commands.add('foo')(lambda context, *args: args)
def test_call_incorrect_args(self):
self.commands.add('foo')(lambda context: context)
with self.assertRaises(TypeError):
self.commands.call(['foo', 'bar'])
self.commands.add('bar')(lambda context, required: context)
with self.assertRaises(TypeError):
self.commands.call(['bar', 'bar', 'baz'])
self.commands.add('baz')(lambda context, optional=None: context)
with self.assertRaises(TypeError):
self.commands.call(['baz', 'bar', 'baz'])
def test_validator_gets_applied_to_required_arg(self):
sentinel = object()
func = lambda context, required: required
self.commands.add('test', required=lambda v: sentinel)(func)
self.assertEqual(sentinel, self.commands.call(['test', 'foo']))
def test_validator_gets_applied_to_optional_arg(self):
sentinel = object()
func = lambda context, optional=None: optional
self.commands.add('foo', optional=lambda v: sentinel)(func)
self.assertEqual(sentinel, self.commands.call(['foo', '123']))
def test_validator_skips_optional_default(self):
sentinel = object()
func = lambda context, optional=sentinel: optional
self.commands.add('foo', optional=lambda v: None)(func)
self.assertEqual(sentinel, self.commands.call(['foo']))
def test_validator_applied_to_non_existent_arg_fails(self):
self.commands.add('foo')(lambda context, arg: arg)
with self.assertRaises(TypeError):
func = lambda context, wrong_arg: wrong_arg
self.commands.add('bar', arg=lambda v: v)(func)
def test_validator_called_context_fails(self):
return # TODO: how to handle this
with self.assertRaises(TypeError):
func = lambda context: True
self.commands.add('bar', context=lambda v: v)(func)
def test_validator_value_error_is_converted(self):
def validdate(value):
raise ValueError
func = lambda context, arg: True
self.commands.add('bar', arg=validdate)(func)
with self.assertRaises(exceptions.MpdArgError):
self.commands.call(['bar', 'test'])
def test_auth_required_gets_stored(self):
func1 = lambda context: context
func2 = lambda context: context
self.commands.add('foo')(func1)
self.commands.add('bar', auth_required=False)(func2)
self.assertTrue(self.commands.handlers['foo'].auth_required)
self.assertFalse(self.commands.handlers['bar'].auth_required)
def test_list_command_gets_stored(self):
func1 = lambda context: context
func2 = lambda context: context
self.commands.add('foo')(func1)
self.commands.add('bar', list_command=False)(func2)
self.assertTrue(self.commands.handlers['foo'].list_command)
self.assertFalse(self.commands.handlers['bar'].list_command)

View File

@ -8,7 +8,6 @@ from mopidy import core
from mopidy.backend import dummy
from mopidy.mpd.dispatcher import MpdDispatcher
from mopidy.mpd.exceptions import MpdAckError
from mopidy.mpd.protocol import request_handlers, handle_request
class MpdDispatcherTest(unittest.TestCase):
@ -25,41 +24,15 @@ class MpdDispatcherTest(unittest.TestCase):
def tearDown(self):
pykka.ActorRegistry.stop_all()
def test_register_same_pattern_twice_fails(self):
func = lambda: None
def test_call_handler_for_unknown_command_raises_exception(self):
try:
handle_request('a pattern')(func)
handle_request('a pattern')(func)
self.fail('Registering a pattern twice shoulde raise ValueError')
except ValueError:
pass
def test_finding_handler_for_unknown_command_raises_exception(self):
try:
self.dispatcher._find_handler('an_unknown_command with args')
self.dispatcher._call_handler('an_unknown_command with args')
self.fail('Should raise exception')
except MpdAckError as e:
self.assertEqual(
e.get_mpd_ack(),
'ACK [5@0] {} unknown command "an_unknown_command"')
def test_find_handler_for_known_command_returns_handler_and_kwargs(self):
expected_handler = lambda x: None
request_handlers['known_command (?P<arg1>.+)'] = \
expected_handler
(handler, kwargs) = self.dispatcher._find_handler(
'known_command an_arg')
self.assertEqual(handler, expected_handler)
self.assertIn('arg1', kwargs)
self.assertEqual(kwargs['arg1'], 'an_arg')
def test_handling_unknown_request_yields_error(self):
result = self.dispatcher.handle_request('an unhandled request')
self.assertEqual(result[0], 'ACK [5@0] {} unknown command "an"')
def test_handling_known_request(self):
expected = 'magic'
request_handlers['known request'] = lambda x: expected
result = self.dispatcher.handle_request('known request')
self.assertIn('OK', result)
self.assertIn(expected, result)

149
tests/mpd/test_tokenizer.py Normal file
View File

@ -0,0 +1,149 @@
#encoding: utf-8
from __future__ import unicode_literals
import unittest
from mopidy.mpd import exceptions, tokenize
class TestTokenizer(unittest.TestCase):
def assertTokenizeEquals(self, expected, line):
self.assertEqual(expected, tokenize.split(line))
def assertTokenizeRaises(self, exception, message, line):
with self.assertRaises(exception) as cm:
tokenize.split(line)
self.assertEqual(cm.exception.message, message)
def test_empty_string(self):
ex = exceptions.MpdNoCommand
msg = 'No command given'
self.assertTokenizeRaises(ex, msg, '')
self.assertTokenizeRaises(ex, msg, ' ')
self.assertTokenizeRaises(ex, msg, '\t\t\t')
def test_command(self):
self.assertTokenizeEquals(['test'], 'test')
self.assertTokenizeEquals(['test123'], 'test123')
self.assertTokenizeEquals(['foo_bar'], 'foo_bar')
def test_command_trailing_whitespace(self):
self.assertTokenizeEquals(['test'], 'test ')
self.assertTokenizeEquals(['test'], 'test\t\t\t')
def test_command_leading_whitespace(self):
ex = exceptions.MpdUnknownError
msg = 'Letter expected'
self.assertTokenizeRaises(ex, msg, ' test')
self.assertTokenizeRaises(ex, msg, '\ttest')
def test_invalid_command(self):
ex = exceptions.MpdUnknownError
msg = 'Invalid word character'
self.assertTokenizeRaises(ex, msg, 'foo/bar')
self.assertTokenizeRaises(ex, msg, 'æøå')
self.assertTokenizeRaises(ex, msg, 'test?')
self.assertTokenizeRaises(ex, msg, 'te"st')
def test_unquoted_param(self):
self.assertTokenizeEquals(['test', 'param'], 'test param')
self.assertTokenizeEquals(['test', 'param'], 'test\tparam')
def test_unquoted_param_leading_whitespace(self):
self.assertTokenizeEquals(['test', 'param'], 'test param')
self.assertTokenizeEquals(['test', 'param'], 'test\t\tparam')
def test_unquoted_param_trailing_whitespace(self):
self.assertTokenizeEquals(['test', 'param'], 'test param ')
self.assertTokenizeEquals(['test', 'param'], 'test param\t\t')
def test_unquoted_param_invalid_chars(self):
ex = exceptions.MpdArgError
msg = 'Invalid unquoted character'
self.assertTokenizeRaises(ex, msg, 'test par"m')
self.assertTokenizeRaises(ex, msg, 'test foo\bbar')
self.assertTokenizeRaises(ex, msg, 'test foo"bar"baz')
self.assertTokenizeRaises(ex, msg, 'test foo\'bar')
def test_unquoted_param_numbers(self):
self.assertTokenizeEquals(['test', '123'], 'test 123')
self.assertTokenizeEquals(['test', '+123'], 'test +123')
self.assertTokenizeEquals(['test', '-123'], 'test -123')
self.assertTokenizeEquals(['test', '3.14'], 'test 3.14')
def test_unquoted_param_extended_chars(self):
self.assertTokenizeEquals(['test', 'æøå'], 'test æøå')
self.assertTokenizeEquals(['test', '?#$'], 'test ?#$')
self.assertTokenizeEquals(['test', '/foo/bar/'], 'test /foo/bar/')
self.assertTokenizeEquals(['test', 'foo\\bar'], 'test foo\\bar')
def test_unquoted_params(self):
self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test foo bar')
def test_quoted_param(self):
self.assertTokenizeEquals(['test', 'param'], 'test "param"')
self.assertTokenizeEquals(['test', 'param'], 'test\t"param"')
def test_quoted_param_leading_whitespace(self):
self.assertTokenizeEquals(['test', 'param'], 'test "param"')
self.assertTokenizeEquals(['test', 'param'], 'test\t\t"param"')
def test_quoted_param_trailing_whitespace(self):
self.assertTokenizeEquals(['test', 'param'], 'test "param" ')
self.assertTokenizeEquals(['test', 'param'], 'test "param"\t\t')
def test_quoted_param_invalid_chars(self):
ex = exceptions.MpdArgError
msg = 'Space expected after closing \'"\''
self.assertTokenizeRaises(ex, msg, 'test "foo"bar"')
self.assertTokenizeRaises(ex, msg, 'test "foo"bar" ')
self.assertTokenizeRaises(ex, msg, 'test "foo"bar')
self.assertTokenizeRaises(ex, msg, 'test "foo"bar ')
def test_quoted_param_numbers(self):
self.assertTokenizeEquals(['test', '123'], 'test "123"')
self.assertTokenizeEquals(['test', '+123'], 'test "+123"')
self.assertTokenizeEquals(['test', '-123'], 'test "-123"')
self.assertTokenizeEquals(['test', '3.14'], 'test "3.14"')
def test_quoted_param_spaces(self):
self.assertTokenizeEquals(['test', 'foo bar'], 'test "foo bar"')
self.assertTokenizeEquals(['test', 'foo bar'], 'test "foo bar"')
self.assertTokenizeEquals(['test', ' param\t'], 'test " param\t"')
def test_quoted_param_extended_chars(self):
self.assertTokenizeEquals(['test', 'æøå'], 'test "æøå"')
self.assertTokenizeEquals(['test', '?#$'], 'test "?#$"')
self.assertTokenizeEquals(['test', '/foo/bar/'], 'test "/foo/bar/"')
def test_quoted_param_escaping(self):
self.assertTokenizeEquals(['test', '\\'], r'test "\\"')
self.assertTokenizeEquals(['test', '"'], r'test "\""')
self.assertTokenizeEquals(['test', ' '], r'test "\ "')
self.assertTokenizeEquals(['test', '\\n'], r'test "\\\n"')
def test_quoted_params(self):
self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test "foo" "bar"')
def test_mixed_params(self):
self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test foo "bar"')
self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test "foo" bar')
self.assertTokenizeEquals(['test', '1', '2'], 'test 1 "2"')
self.assertTokenizeEquals(['test', '1', '2'], 'test "1" 2')
self.assertTokenizeEquals(['test', 'foo bar', 'baz', '123'],
'test "foo bar" baz 123')
self.assertTokenizeEquals(['test', 'foo"bar', 'baz', '123'],
r'test "foo\"bar" baz 123')
def test_unbalanced_quotes(self):
ex = exceptions.MpdArgError
msg = 'Invalid unquoted character'
self.assertTokenizeRaises(ex, msg, 'test "foo bar" baz"')
def test_missing_closing_quote(self):
ex = exceptions.MpdArgError
msg = 'Missing closing \'"\''
self.assertTokenizeRaises(ex, msg, 'test "foo')
self.assertTokenizeRaises(ex, msg, 'test "foo a ')