chiark / gitweb /
More tests for mime.c
[disorder] / python / disorder.py.in
index 5becda13e6dd771e5afdc8a4643e51f1ad4ed733..36157a0a8dbb39e065f10bca3ba91b5b7bd087ae 100644 (file)
@@ -1,5 +1,5 @@
 #
 #
-# Copyright (C) 2004, 2005 Richard Kettlewell
+# Copyright (C) 2004, 2005, 2007 Richard Kettlewell
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -39,6 +39,11 @@ Example 2:
   for path in sys.argv[1:]:
     d.play(path)
 
   for path in sys.argv[1:]:
     d.play(path)
 
+See disorder_protocol(5) for details of the communication protocol.
+
+NB that this code only supports servers configured to use SHA1-based
+authentication.  If the server demands another hash then it will not be
+possible to use this module.
 """
 
 import re
 """
 
 import re
@@ -105,15 +110,20 @@ class operationError(Error):
   Indicates that an operation failed (e.g. an attempt to play a
   nonexistent track).  The connection should still be usable.
   """
   Indicates that an operation failed (e.g. an attempt to play a
   nonexistent track).  The connection should still be usable.
   """
-  def __init__(self, res, details):
+  def __init__(self, res, details, cmd=None):
     self.res_ = int(res)
     self.res_ = int(res)
+    self.cmd_ = cmd
     self.details_ = details
   def __str__(self):
     self.details_ = details
   def __str__(self):
-    """Return the complete response string from the server.
+    """Return the complete response string from the server, with the command
+    if available.
 
     Excludes the final newline.
     """
 
     Excludes the final newline.
     """
-    return "%d %s" % (self.res_, self.details_)
+    if self.cmd_ is None:
+      return "%d %s" % (self.res_, self.details_)
+    else:
+      return "%d %s [%s]" % (self.res_, self.details_, self.cmd_)
   def response(self):
     """Return the response code from the server."""
     return self.res_
   def response(self):
     """Return the response code from the server."""
     return self.res_
@@ -226,7 +236,7 @@ def _list2dict(l):
     while True:
       k = i.next()
       v = i.next()
     while True:
       k = i.next()
       v = i.next()
-      d[k] = v
+      d[str(k)] = v
   except StopIteration:
     pass
   return d
   except StopIteration:
     pass
   return d
@@ -266,7 +276,7 @@ class client:
   debug_proto = 0x0001
   debug_body = 0x0002
 
   debug_proto = 0x0001
   debug_body = 0x0002
 
-  def __init__(self):
+  def __init__(self, user=None, password=None):
     """Constructor for DisOrder client class.
 
     The constructor reads the configuration file, but does not connect
     """Constructor for DisOrder client class.
 
     The constructor reads the configuration file, but does not connect
@@ -284,12 +294,15 @@ class client:
     self.config = { 'collections': [],
                     'username': pw.pw_name,
                     'home': _dbhome }
     self.config = { 'collections': [],
                     'username': pw.pw_name,
                     'home': _dbhome }
+    self.user = user
+    self.password = password
     home = os.getenv("HOME")
     if not home:
       home = pw.pw_dir
     privconf = _configfile + "." + pw.pw_name
     passfile = home + os.sep + ".disorder" + os.sep + "passwd"
     home = os.getenv("HOME")
     if not home:
       home = pw.pw_dir
     privconf = _configfile + "." + pw.pw_name
     passfile = home + os.sep + ".disorder" + os.sep + "passwd"
-    self._readfile(_configfile)
+    if os.path.exists(_configfile):
+      self._readfile(_configfile)
     if os.path.exists(privconf):
       self._readfile(privconf)
     if os.path.exists(passfile) and _userconf:
     if os.path.exists(privconf):
       self._readfile(privconf)
     if os.path.exists(passfile) and _userconf:
@@ -316,8 +329,10 @@ class client:
       sys.stderr.write("\n")
       sys.stderr.flush()
 
       sys.stderr.write("\n")
       sys.stderr.flush()
 
-  def connect(self):
-    """Connect to the DisOrder server and authenticate.
+  def connect(self, cookie=None):
+    """c.connect(cookie=None)
+
+    Connect to the DisOrder server and authenticate.
 
     Raises communicationError if connection fails and operationError if
     authentication fails (in which case disconnection is automatic).
 
     Raises communicationError if connection fails and operationError if
     authentication fails (in which case disconnection is automatic).
@@ -328,6 +343,9 @@ class client:
 
     Other operations automatically connect if we're not already
     connected, so it is not strictly necessary to call this method.
 
     Other operations automatically connect if we're not already
     connected, so it is not strictly necessary to call this method.
+
+    If COOKIE is specified then that is used to log in instead of
+    the username/password.
     """
     if self.state == 'disconnected':
       try:
     """
     if self.state == 'disconnected':
       try:
@@ -357,11 +375,27 @@ class client:
           s.connect(self.who)
         self.w = s.makefile("wb")
         self.r = s.makefile("rb")
           s.connect(self.who)
         self.w = s.makefile("wb")
         self.r = s.makefile("rb")
-        (res, challenge) = self._simple()
-        h = sha.sha()
-        h.update(self.config['password'])
-        h.update(binascii.unhexlify(challenge))
-        self._simple("user", self.config['username'], h.hexdigest())
+        (res, details) = self._simple()
+        (protocol, algo, challenge) = _split(details)
+        if protocol != '2':
+          raise communicationError(self.who,
+                                   "unknown protocol version %s" % protocol)
+        if cookie is None:
+          if self.user is None:
+            user = self.config['username']
+          else:
+            user = self.user
+          if self.password is None:
+            password = self.config['password']
+          else:
+            password = self.password
+          # TODO support algorithms other than SHA-1
+          h = sha.sha()
+          h.update(password)
+          h.update(binascii.unhexlify(challenge))
+          self._simple("user", user, h.hexdigest())
+        else:
+          self._simple("cookie", cookie)
         self.state = 'connected'
       except socket.error, e:
         self._disconnect()
         self.state = 'connected'
       except socket.error, e:
         self._disconnect()
@@ -382,23 +416,19 @@ class client:
   ########################################################################
   # Operations
 
   ########################################################################
   # Operations
 
-  def become(self, who):
-    """Become another user.
-
-    Arguments:
-    who -- the user to become.
-
-    Only trusted users can perform this operation.
-    """
-    self._simple("become", who)
-
   def play(self, track):
     """Play a track.
 
     Arguments:
     track -- the path of the track to play.
   def play(self, track):
     """Play a track.
 
     Arguments:
     track -- the path of the track to play.
+
+    Returns the ID of the new queue entry.
+
+    Note that queue IDs are unicode strings (because all track information
+    values are unicode strings).
     """
     """
-    self._simple("play", track)
+    res, details = self._simple("play", track)
+    return unicode(details)             # because it's unicode in queue() output
 
   def remove(self, track):
     """Remove a track from the queue.
 
   def remove(self, track):
     """Remove a track from the queue.
@@ -461,12 +491,15 @@ class client:
 
   def version(self):
     """Return the server's version number."""
 
   def version(self):
     """Return the server's version number."""
-    return self._simple("version")[1]
+    return _split(self._simple("version")[1])[0]
 
   def playing(self):
     """Return the currently playing track.
 
 
   def playing(self):
     """Return the currently playing track.
 
-    If a track is playing then it is returned as a dictionary.
+    If a track is playing then it is returned as a dictionary.  See
+    disorder_protocol(5) for the meanings of the keys.  All keys are
+    plain strings but the values will be unicode strings.
+    
     If no track is playing then None is returned."""
     res, details = self._simple("playing")
     if res % 10 != 9:
     If no track is playing then None is returned."""
     res, details = self._simple("playing")
     if res % 10 != 9:
@@ -488,14 +521,20 @@ class client:
     """Return a list of recently played tracks.
 
     The return value is a list of dictionaries corresponding to
     """Return a list of recently played tracks.
 
     The return value is a list of dictionaries corresponding to
-    recently played tracks.  The oldest track comes first."""
+    recently played tracks.  The oldest track comes first.
+
+    See disorder_protocol(5) for the meanings of the keys.  All keys are
+    plain strings but the values will be unicode strings."""
     return self._somequeue("recent")
 
   def queue(self):
     """Return the current queue.
 
     The return value is a list of dictionaries corresponding to
     return self._somequeue("recent")
 
   def queue(self):
     """Return the current queue.
 
     The return value is a list of dictionaries corresponding to
-    recently played tracks.  The next track to be played comes first."""
+    recently played tracks.  The next track to be played comes first.
+
+    See disorder_protocol(5) for the meanings of the keys.  All keys are
+    plain strings but the values will be unicode strings."""
     return self._somequeue("queue")
 
   def _somedir(self, command, dir, re):
     return self._somequeue("queue")
 
   def _somedir(self, command, dir, re):
@@ -577,10 +616,13 @@ class client:
     track -- the track to query
     key -- the preference to remove
 
     track -- the track to query
     key -- the preference to remove
 
-    The return value is the preference 
+    The return value is the preference.
     """
     ret, details = self._simple("get", track, key)
     """
     ret, details = self._simple("get", track, key)
-    return details
+    if ret == 555:
+      return None
+    else:
+      return _split(details)[0]
 
   def prefs(self, track):
     """Get all the preferences for a track.
 
   def prefs(self, track):
     """Get all the preferences for a track.
@@ -648,7 +690,15 @@ class client:
     all of the required words (in their path name, trackname
     preferences, etc.)
     """
     all of the required words (in their path name, trackname
     preferences, etc.)
     """
-    self._simple("search", *words)
+    self._simple("search", _quote(words))
+    return self._body()
+
+  def tags(self):
+    """List all tags
+
+    The return value is a list of all tags which apply to at least one
+    track."""
+    self._simple("tags")
     return self._body()
 
   def stats(self):
     return self._body()
 
   def stats(self):
@@ -694,6 +744,21 @@ class client:
     ret, details = self._simple("move", track, str(delta))
     return int(details)
 
     ret, details = self._simple("move", track, str(delta))
     return int(details)
 
+  def moveafter(self, target, tracks):
+    """Move a track in the queue
+
+    Arguments:
+    target -- target ID or None
+    tracks -- a list of IDs to move
+
+    If target is '' or is not in the queue then the tracks are moved to
+    the head of the queue.
+
+    Otherwise the tracks are moved to just after the target."""
+    if target is None:
+      target = ''
+    self._simple("moveafter", target, *tracks)
+
   def log(self, callback):
     """Read event log entries as they happen.
 
   def log(self, callback):
     """Read event log entries as they happen.
 
@@ -748,7 +813,82 @@ class client:
     The return value is the preference 
     """
     ret, details = self._simple("part", track, context, part)
     The return value is the preference 
     """
     ret, details = self._simple("part", track, context, part)
-    return details
+    return _split(details)[0]
+
+  def setglobal(self, key, value):
+    """Set a global preference value.
+
+    Arguments:
+    key -- the preference name
+    value -- the new preference value
+    """
+    self._simple("set-global", key, value)
+
+  def unsetglobal(self, key):
+    """Unset a global preference value.
+
+    Arguments:
+    key -- the preference to remove
+    """
+    self._simple("set-global", key, value)
+
+  def getglobal(self, key):
+    """Get a global preference value.
+
+    Arguments:
+    key -- the preference to look up
+
+    The return value is the preference 
+    """
+    ret, details = self._simple("get-global", key)
+    if ret == 555:
+      return None
+    else:
+      return _split(details)[0]
+
+  def make_cookie(self):
+    """Create a login cookie"""
+    ret, details = self._simple("make-cookie")
+    return _split(details)[0]
+  
+  def revoke(self):
+    """Revoke a login cookie"""
+    self._simple("revoke")
+
+  def adduser(self, user, password):
+    """Create a user"""
+    self._simple("adduser", user, password)
+
+  def deluser(self, user):
+    """Delete a user"""
+    self._simple("deluser", user)
+
+  def userinfo(self, user, key):
+    """Get user information"""
+    res, details = self._simple("userinfo", user, key)
+    if res == 555:
+      return None
+    return _split(details)[0]
+
+  def edituser(self, user, key, value):
+    """Set user information"""
+    self._simple("edituser", user, key, value)
+
+  def users(self):
+    """List all users
+
+    The return value is a list of all users."""
+    self._simple("users")
+    return self._body()
+
+  def register(self, username, password, email):
+    """Register a user"""
+    res, details = self._simple("register", username, password, email)
+    return _split(details)[0]
+
+  def confirm(self, confirmation):
+    """Confirm a user registration"""
+    res, details = self._simple("confirm", confirmation)
 
   ########################################################################
   # I/O infrastructure
 
   ########################################################################
   # I/O infrastructure
@@ -780,6 +920,9 @@ class client:
       raise protocolError(self.who, "invalid response %s")
 
   def _send(self, *command):
       raise protocolError(self.who, "invalid response %s")
 
   def _send(self, *command):
+    # Quote and send a command
+    #
+    # Returns the encoded command.
     quoted = _quote(command)
     self._debug(client.debug_proto, "==> %s" % quoted)
     encoded = quoted.encode("UTF-8")
     quoted = _quote(command)
     self._debug(client.debug_proto, "==> %s" % quoted)
     encoded = quoted.encode("UTF-8")
@@ -787,6 +930,7 @@ class client:
       self.w.write(encoded)
       self.w.write("\n")
       self.w.flush()
       self.w.write(encoded)
       self.w.write("\n")
       self.w.flush()
+      return encoded
     except IOError, e:
       # e.g. EPIPE
       self._disconnect()
     except IOError, e:
       # e.g. EPIPE
       self._disconnect()
@@ -800,17 +944,19 @@ class client:
     #
     # If an I/O error occurs, disconnect from the server.
     #
     #
     # If an I/O error occurs, disconnect from the server.
     #
-    # On success returns response as a (code, details) tuple
+    # On success or 'normal' errors returns response as a (code, details) tuple
     #
     # On error raise operationError
     if self.state == 'disconnected':
       self.connect()
     if command:
     #
     # On error raise operationError
     if self.state == 'disconnected':
       self.connect()
     if command:
-      self._send(*command)
+      cmd = self._send(*command)
+    else:
+      cmd = None
     res, details = self._response()
     res, details = self._response()
-    if res / 100 == 2:
+    if res / 100 == 2 or res == 555:
       return res, details
       return res, details
-    raise operationError(res, details)
+    raise operationError(res, details, cmd)
 
   def _body(self):
     # Fetch a dot-stuffed body
 
   def _body(self):
     # Fetch a dot-stuffed body