diff --git a/docs/changelog.rst b/docs/changelog.rst index b17e5e27..c7b0a078 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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`) diff --git a/docs/modules/mpd.rst b/docs/modules/mpd.rst index 4a9eb7e8..1826e535 100644 --- a/docs/modules/mpd.rst +++ b/docs/modules/mpd.rst @@ -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 ============== diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 0a916408..a53d387b 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -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) diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index ec874553..6738b4c9 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -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' diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index 8a0993d8..e24af565 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -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.+)$') - 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() diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 802be6c0..868edc49 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -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\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\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:* diff --git a/mopidy/mpd/protocol/channels.py b/mopidy/mpd/protocol/channels.py index e8efd2a0..4ae00622 100644 --- a/mopidy/mpd/protocol/channels.py +++ b/mopidy/mpd/protocol/channels.py @@ -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[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[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[A-Za-z0-9:._-]+)"\ "(?P[^"]*)"$') +@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 diff --git a/mopidy/mpd/protocol/command_list.py b/mopidy/mpd/protocol/command_list.py index 8268c55d..d8551105 100644 --- a/mopidy/mpd/protocol/command_list.py +++ b/mopidy/mpd/protocol/command_list.py @@ -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 diff --git a/mopidy/mpd/protocol/connection.py b/mopidy/mpd/protocol/connection.py index 41ee9e6a..41896acf 100644 --- a/mopidy/mpd/protocol/connection.py +++ b/mopidy/mpd/protocol/connection.py @@ -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[^"]+)"$', 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:* diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index de8721d3..c2e54490 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -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[^"]*)"$') +@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[^"]*)"(\ "(?P\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\d+):(?P\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\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\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\d+):(?P\d+)*"\ "(?P\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\d+)"\ "(?P\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\d+)"\ "(?P\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[^"]+)\1\ "(?P[^"]+)"$') +@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\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-?\d+)"$') -@handle_request(r'playlistinfo\ "(?P\d+):(?P\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\w+)\1\ "(?P[^"]+)"$') +@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-?\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\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\d+):(?P\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\d+)"\ "(?P\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\d+)"\ "(?P\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 diff --git a/mopidy/mpd/protocol/empty.py b/mopidy/mpd/protocol/empty.py deleted file mode 100644 index 64cfc1fb..00000000 --- a/mopidy/mpd/protocol/empty.py +++ /dev/null @@ -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() diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index e821af6b..3da9c5aa 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -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 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.*) - )? - $ -""" - -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 - (?: # 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[^"]+)"$') +@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[^"]+)"$') +@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[^"]*)"$') +@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[^"]+)"$') +@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[^"]+)"\ ' + 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[^"]+)"$') -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 diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 4f8ae73a..a3de1891 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -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[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\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[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-?\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-?\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[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[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(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\d+)\1\ ("?)(?P\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\d+)"\ "(?P\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\d+)"$') -@handle_request(r'seekcur\ "(?P[-+]\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[-+]*\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[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:* diff --git a/mopidy/mpd/protocol/reflection.py b/mopidy/mpd/protocol/reflection.py index 79aa1247..4308c560 100644 --- a/mopidy/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -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:* diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 96bca6d6..8f97c2e4 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -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.+)$') -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:* diff --git a/mopidy/mpd/protocol/stickers.py b/mopidy/mpd/protocol/stickers.py index 17798523..4d535423 100644 --- a/mopidy/mpd/protocol/stickers.py +++ b/mopidy/mpd/protocol/stickers.py @@ -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[^"]+)"\ ' - r'"(?P[^"]+)"(\ "(?P[^"]+)")*$') -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[^"]+)"\ "(?P[^"]+)"\ ' - r'"(?P[^"]+)"$') -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[^"]+)"\ "(?P[^"]+)"\ ' - r'"(?P[^"]+)"$') -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[^"]+)"\ "(?P[^"]+)"$') -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[^"]+)"\ "(?P[^"]+)"\ ' - r'"(?P[^"]+)"\ "(?P[^"]+)"$') -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 diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index 571dde25..f4d48ff0 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -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[^"]+)\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[^"]+)\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[^"]+)"(\ "(?P\d+):(?P\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[^"]+)"\ "(?P[^"]+)"$') +@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[^"]+)"$') +@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[^"]+)"\ "(?P\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[^"]+)"\ ' - r'"(?P\d+)"\ "(?P\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[^"]+)"\ "(?P[^"]+)"$') +@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[^"]+)"$') +@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[^"]+)"$') +@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 diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 2c0bd840..f0317ede 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -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 ' diff --git a/mopidy/mpd/tokenize.py b/mopidy/mpd/tokenize.py new file mode 100644 index 00000000..bc0d6b3f --- /dev/null +++ b/mopidy/mpd/tokenize.py @@ -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' diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 520e9ac8..252725ee 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -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') diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index e2db8b05..e9898dd9 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -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') diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index c0dcf83d..93e45a2e 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -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') diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py new file mode 100644 index 00000000..91a9125a --- /dev/null +++ b/tests/mpd/test_commands.py @@ -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) diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index c4da1714..cee4531a 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -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.+)'] = \ - 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) diff --git a/tests/mpd/test_tokenizer.py b/tests/mpd/test_tokenizer.py new file mode 100644 index 00000000..546df847 --- /dev/null +++ b/tests/mpd/test_tokenizer.py @@ -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 ')