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/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index 1be42dcc..7d009022 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -37,6 +37,7 @@ def load_protocol_modules(): 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()? @@ -44,6 +45,7 @@ def INT(value): def UINT(value): + """Converts a value that matches \d+ into and integer.""" if value is None: raise ValueError('None is not a valid integer') if not value.isdigit(): @@ -52,13 +54,19 @@ def UINT(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): - # TODO: test and check that values are positive + """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) @@ -75,10 +83,38 @@ def RANGE(value): 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 + validation rules. + + Additional keyword arguments are treated as converts/validators to + apply to tokens converting them to proper python types. + + 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) @@ -118,12 +154,21 @@ class Commands(object): return func return wrapper - def call(self, args, context=None): - if not args: + 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 there 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 args[0] not in self.handlers: - raise exceptions.MpdUnknownCommand(command=args[0]) - return self.handlers[args[0]](context, *args[1:]) + 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 diff --git a/mopidy/mpd/tokenize.py b/mopidy/mpd/tokenize.py index 195209e3..bc0d6b3f 100644 --- a/mopidy/mpd/tokenize.py +++ b/mopidy/mpd/tokenize.py @@ -39,6 +39,23 @@ 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) @@ -60,6 +77,7 @@ def split(line): 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: