chiark / gitweb /
disorder.py docs improved a little
[disorder] / python / disorder.py.in
1 #
2 # Copyright (C) 2004, 2005, 2007 Richard Kettlewell
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12 # General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
17 # USA
18 #
19
20 """Python support for DisOrder
21
22 Provides disorder.client, a class for accessing a DisOrder server.
23
24 Example 1:
25
26   #! /usr/bin/env python
27   import disorder
28   d = disorder.client()
29   p = d.playing()
30   if p:
31     print p['track']
32
33 Example 2:
34
35   #! /usr/bin/env python
36   import disorder
37   import sys
38   d = disorder.client()
39   for path in sys.argv[1:]:
40     d.play(path)
41
42 See disorder_protocol(5) for details of the communication protocol.
43
44 NB that this code only supports servers configured to use SHA1-based
45 authentication.  If the server demands another hash then it will not be
46 possible to use this module.
47 """
48
49 import re
50 import string
51 import os
52 import pwd
53 import socket
54 import binascii
55 import sha
56 import sys
57 import locale
58
59 _configfile = "pkgconfdir/config"
60 _dbhome = "pkgstatedir"
61 _userconf = True
62
63 # various regexps we'll use
64 _ws = re.compile(r"^[ \t\n\r]+")
65 _squote = re.compile("'(([^\\\\']|\\\\[\\\\\"'n])+)'")
66 _dquote = re.compile("\"(([^\\\\\"]|\\\\[\\\\\"'n])+)\"")
67 _unquoted = re.compile("[^\"' \\t\\n\\r][^ \t\n\r]*")
68
69 _response = re.compile("([0-9]{3}) ?(.*)")
70
71 version = "_version_"
72
73 ########################################################################
74 # exception classes
75
76 class Error(Exception):
77   """Base class for DisOrder exceptions."""
78
79 class _splitError(Error):
80   # _split failed
81   def __init__(self, value):
82     self.value = value
83   def __str__(self):
84     return str(self.value)
85
86 class parseError(Error):
87   """Error parsing the configuration file."""
88   def __init__(self, path, line, details):
89     self.path = path
90     self.line = line
91     self.details = details
92   def __str__(self):
93     return "%s:%d: %s" % (self.path, self.line, self.details)
94
95 class protocolError(Error):
96   """DisOrder control protocol error.
97
98   Indicates a mismatch between the client and server's understanding of
99   the control protocol.
100   """
101   def __init__(self, who, error):
102     self.who = who
103     self.error = error
104   def __str__(self):
105     return "%s: %s" % (self.who, str(self.error))
106
107 class operationError(Error):
108   """DisOrder control protocol error response.
109
110   Indicates that an operation failed (e.g. an attempt to play a
111   nonexistent track).  The connection should still be usable.
112   """
113   def __init__(self, res, details, cmd=None):
114     self.res_ = int(res)
115     self.cmd_ = cmd
116     self.details_ = details
117   def __str__(self):
118     """Return the complete response string from the server, with the command
119     if available.
120
121     Excludes the final newline.
122     """
123     if self.cmd_ is None:
124       return "%d %s" % (self.res_, self.details_)
125     else:
126       return "%d %s [%s]" % (self.res_, self.details_, self.cmd_)
127   def response(self):
128     """Return the response code from the server."""
129     return self.res_
130   def details(self):
131     """Returns the detail string from the server."""
132     return self.details_
133
134 class communicationError(Error):
135   """DisOrder control protocol communication error.
136
137   Indicates that communication with the server went wrong, perhaps
138   because the server was restarted.  The caller could report an error to
139   the user and wait for further user instructions, or even automatically
140   retry the operation.
141   """
142   def __init__(self, who, error):
143     self.who = who
144     self.error = error
145   def __str__(self):
146     return "%s: %s" % (self.who, str(self.error))
147
148 ########################################################################
149 # DisOrder-specific text processing
150
151 def _unescape(s):
152   # Unescape the contents of a string
153   #
154   # Arguments:
155   #
156   # s -- string to unescape
157   #
158   s = re.sub("\\\\n", "\n", s)
159   s = re.sub("\\\\(.)", "\\1", s)
160   return s
161
162 def _split(s, *comments):
163   # Split a string into fields according to the usual Disorder string splitting
164   # conventions.
165   #
166   # Arguments:
167   #
168   # s        -- string to parse
169   # comments -- if present, parse comments
170   #
171   # Return values:
172   #
173   # On success, a list of fields is returned.
174   #
175   # On error, disorder.parseError is thrown.
176   #
177   fields = []
178   while s != "":
179     # discard comments
180     if comments and s[0] == '#':
181       break
182     # strip spaces
183     m = _ws.match(s)
184     if m:
185       s = s[m.end():]
186       continue
187     # pick of quoted fields of both kinds
188     m = _squote.match(s)
189     if not m:
190       m = _dquote.match(s)
191     if m:
192       fields.append(_unescape(m.group(1)))
193       s = s[m.end():]
194       continue
195     # and unquoted fields
196     m = _unquoted.match(s)
197     if m:
198       fields.append(m.group(0))
199       s = s[m.end():]
200       continue
201     # anything left must be in error
202     if s[0] == '"' or s[0] == '\'':
203       raise _splitError("invalid quoted string")
204     else:
205       raise _splitError("syntax error")
206   return fields
207
208 def _escape(s):
209   # Escape the contents of a string
210   #
211   # Arguments:
212   #
213   # s -- string to escape
214   #
215   if re.search("[\\\\\"'\n \t\r]", s) or s == '':
216     s = re.sub(r'[\\"]', r'\\\g<0>', s)
217     s = re.sub("\n", r"\\n", s)
218     return '"' + s + '"'
219   else:
220     return s
221
222 def _quote(list):
223   # Quote a list of values
224   return ' '.join(map(_escape, list))
225
226 def _sanitize(s):
227   # Return the value of s in a form suitable for writing to stderr
228   return s.encode(locale.nl_langinfo(locale.CODESET), 'replace')
229
230 def _list2dict(l):
231   # Convert a list of the form [k1, v1, k2, v2, ..., kN, vN]
232   # to a dictionary {k1:v1, k2:v2, ..., kN:vN}
233   d = {}
234   i = iter(l)
235   try:
236     while True:
237       k = i.next()
238       v = i.next()
239       d[str(k)] = v
240   except StopIteration:
241     pass
242   return d
243
244 def _queueEntry(s):
245   # parse a queue entry
246   return _list2dict(_split(s))
247
248 ########################################################################
249 # The client class
250
251 class client:
252   """DisOrder client class.
253
254   This class provides access to the DisOrder server either on this
255   machine or across the internet.
256
257   The server to connect to, and the username and password to use, are
258   determined from the configuration files as described in 'man
259   disorder_config'.
260
261   All methods will connect if necessary, as soon as you have a
262   disorder.client object you can start calling operational methods on
263   it.
264
265   However if the server is restarted then the next method called on a
266   connection will throw an exception.  This may be considered a bug.
267
268   All methods block until they complete.
269
270   Operation methods raise communicationError if the connection breaks,
271   protocolError if the response from the server is malformed, or
272   operationError if the response is valid but indicates that the
273   operation failed.
274   """
275
276   debug_proto = 0x0001
277   debug_body = 0x0002
278
279   def __init__(self):
280     """Constructor for DisOrder client class.
281
282     The constructor reads the configuration file, but does not connect
283     to the server.
284
285     If the environment variable DISORDER_PYTHON_DEBUG is set then the
286     debug flags are initialised to that value.  This can be overridden
287     with the debug() method below.
288
289     The constructor Raises parseError() if the configuration file is not
290     valid.
291     """
292     pw = pwd.getpwuid(os.getuid())
293     self.debugging = int(os.getenv("DISORDER_PYTHON_DEBUG", 0))
294     self.config = { 'collections': [],
295                     'username': pw.pw_name,
296                     'home': _dbhome }
297     home = os.getenv("HOME")
298     if not home:
299       home = pw.pw_dir
300     privconf = _configfile + "." + pw.pw_name
301     passfile = home + os.sep + ".disorder" + os.sep + "passwd"
302     if os.path.exists(_configfile):
303       self._readfile(_configfile)
304     if os.path.exists(privconf):
305       self._readfile(privconf)
306     if os.path.exists(passfile) and _userconf:
307       self._readfile(passfile)
308     self.state = 'disconnected'
309
310   def debug(self, bits):
311     """Enable or disable protocol debugging.  Debug messages are written
312     to sys.stderr.
313
314     Arguments:
315     bits -- bitmap of operations that should generate debug information
316
317     Bitmap values:
318     debug_proto -- dump control protocol messages (excluding bodies)
319     debug_body -- dump control protocol message bodies
320     """
321     self.debugging = bits
322
323   def _debug(self, bit, s):
324     # debug output
325     if self.debugging & bit:
326       sys.stderr.write(_sanitize(s))
327       sys.stderr.write("\n")
328       sys.stderr.flush()
329
330   def connect(self):
331     """Connect to the DisOrder server and authenticate.
332
333     Raises communicationError if connection fails and operationError if
334     authentication fails (in which case disconnection is automatic).
335
336     May be called more than once to retry connections (e.g. when the
337     server is down).  If we are already connected and authenticated,
338     this is a no-op.
339
340     Other operations automatically connect if we're not already
341     connected, so it is not strictly necessary to call this method.
342     """
343     if self.state == 'disconnected':
344       try:
345         self.state = 'connecting'
346         if 'connect' in self.config and len(self.config['connect']) > 0:
347           c = self.config['connect']
348           self.who = repr(c)            # temporarily
349           if len(c) == 1:
350             a = socket.getaddrinfo(None, c[0],
351                                    socket.AF_INET,
352                                    socket.SOCK_STREAM,
353                                    0,
354                                    0)
355           else:
356             a = socket.getaddrinfo(c[0], c[1],
357                                    socket.AF_INET,
358                                    socket.SOCK_STREAM,
359                                    0,
360                                    0)
361           a = a[0]
362           s = socket.socket(a[0], a[1], a[2]);
363           s.connect(a[4])
364           self.who = "%s" % a[3]
365         else:
366           s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM);
367           self.who = self.config['home'] + os.sep + "socket"
368           s.connect(self.who)
369         self.w = s.makefile("wb")
370         self.r = s.makefile("rb")
371         (res, challenge) = self._simple()
372         h = sha.sha()
373         h.update(self.config['password'])
374         h.update(binascii.unhexlify(challenge))
375         self._simple("user", self.config['username'], h.hexdigest())
376         self.state = 'connected'
377       except socket.error, e:
378         self._disconnect()
379         raise communicationError(self.who, e)
380       except:
381         self._disconnect()
382         raise
383
384   def _disconnect(self):
385     # disconnect from the server, whatever state we are in
386     try:
387       del self.w
388       del self.r
389     except:
390       pass
391     self.state = 'disconnected'
392     
393   ########################################################################
394   # Operations
395
396   def become(self, who):
397     """Become another user.
398
399     Arguments:
400     who -- the user to become.
401
402     Only trusted users can perform this operation.
403     """
404     self._simple("become", who)
405
406   def play(self, track):
407     """Play a track.
408
409     Arguments:
410     track -- the path of the track to play.
411
412     Returns the ID of the new queue entry.
413
414     Note that queue IDs are unicode strings (because all track information
415     values are unicode strings).
416     """
417     res, details = self._simple("play", track)
418     return unicode(details)             # because it's unicode in queue() output
419
420   def remove(self, track):
421     """Remove a track from the queue.
422
423     Arguments:
424     track -- the path or ID of the track to remove.
425     """
426     self._simple("remove", track)
427
428   def enable(self):
429     """Enable playing."""
430     self._simple("enable")
431
432   def disable(self, *now):
433     """Disable playing.
434
435     Arguments:
436     now -- if present (with any value), the current track is stopped
437            too.
438     """
439     if now:
440       self._simple("disable", "now")
441     else:
442       self._simple("disable")
443
444   def scratch(self, *id):
445     """Scratch the currently playing track.
446
447     Arguments:
448     id -- if present, the ID of the track to scratch.
449     """
450     if id:
451       self._simple("scratch", id[0])
452     else:
453       self._simple("scratch")
454
455   def shutdown(self):
456     """Shut down the server.
457
458     Only trusted users can perform this operation.
459     """
460     self._simple("shutdown")
461
462   def reconfigure(self):
463     """Make the server reload its configuration.
464
465     Only trusted users can perform this operation.
466     """
467     self._simple("reconfigure")
468
469   def rescan(self, pattern):
470     """Rescan one or more collections.
471
472     Arguments:
473     pattern -- glob pattern matching collections to rescan.
474
475     Only trusted users can perform this operation.
476     """
477     self._simple("rescan", pattern)
478
479   def version(self):
480     """Return the server's version number."""
481     return self._simple("version")[1]
482
483   def playing(self):
484     """Return the currently playing track.
485
486     If a track is playing then it is returned as a dictionary.  See
487     disorder_protocol(5) for the meanings of the keys.  All keys are
488     plain strings but the values will be unicode strings.
489     
490     If no track is playing then None is returned."""
491     res, details = self._simple("playing")
492     if res % 10 != 9:
493       try:
494         return _queueEntry(details)
495       except _splitError, s:
496         raise protocolError(self.who, s.str())
497     else:
498       return None
499
500   def _somequeue(self, command):
501     self._simple(command)
502     try:
503       return map(lambda s: _queueEntry(s), self._body())
504     except _splitError, s:
505       raise protocolError(self.who, s.str())
506
507   def recent(self):
508     """Return a list of recently played tracks.
509
510     The return value is a list of dictionaries corresponding to
511     recently played tracks.  The oldest track comes first.
512
513     See disorder_protocol(5) for the meanings of the keys.  All keys are
514     plain strings but the values will be unicode strings."""
515     return self._somequeue("recent")
516
517   def queue(self):
518     """Return the current queue.
519
520     The return value is a list of dictionaries corresponding to
521     recently played tracks.  The next track to be played comes first.
522
523     See disorder_protocol(5) for the meanings of the keys.  All keys are
524     plain strings but the values will be unicode strings."""
525     return self._somequeue("queue")
526
527   def _somedir(self, command, dir, re):
528     if re:
529       self._simple(command, dir, re[0])
530     else:
531       self._simple(command, dir)
532     return self._body()
533
534   def directories(self, dir, *re):
535     """List subdirectories of a directory.
536
537     Arguments:
538     dir -- directory to list, or '' for the whole root.
539     re -- regexp that results must match.  Optional.
540
541     The return value is a list of the (nonempty) subdirectories of dir.
542     If dir is '' then a list of top-level directories is returned.
543
544     If a regexp is specified then the basename of each result must
545     match.  Matching is case-independent.  See pcrepattern(3).
546     """
547     return self._somedir("dirs", dir, re)
548   
549   def files(self, dir, *re):
550     """List files within a directory.
551
552     Arguments:
553     dir -- directory to list, or '' for the whole root.
554     re -- regexp that results must match.  Optional.
555
556     The return value is a list of playable files in dir.  If dir is ''
557     then a list of top-level files is returned.
558
559     If a regexp is specified then the basename of each result must
560     match.  Matching is case-independent.  See pcrepattern(3).
561     """
562     return self._somedir("files", dir, re)
563
564   def allfiles(self, dir, *re):
565     """List subdirectories and files within a directory.
566
567     Arguments:
568     dir -- directory to list, or '' for the whole root.
569     re -- regexp that results must match.  Optional.
570
571     The return value is a list of all (nonempty) subdirectories and
572     files within dir.  If dir is '' then a list of top-level files and
573     directories is returned.
574     
575     If a regexp is specified then the basename of each result must
576     match.  Matching is case-independent.  See pcrepattern(3).
577     """
578     return self._somedir("allfiles", dir, re)
579
580   def set(self, track, key, value):
581     """Set a preference value.
582
583     Arguments:
584     track -- the track to modify
585     key -- the preference name
586     value -- the new preference value
587     """
588     self._simple("set", track, key, value)
589
590   def unset(self, track, key):
591     """Unset a preference value.
592
593     Arguments:
594     track -- the track to modify
595     key -- the preference to remove
596     """
597     self._simple("set", track, key, value)
598
599   def get(self, track, key):
600     """Get a preference value.
601
602     Arguments:
603     track -- the track to query
604     key -- the preference to remove
605
606     The return value is the preference.
607     """
608     ret, details = self._simple("get", track, key)
609     if ret == 555:
610       return None
611     else:
612       return details
613
614   def prefs(self, track):
615     """Get all the preferences for a track.
616
617     Arguments:
618     track -- the track to query
619
620     The return value is a dictionary of all the track's preferences.
621     Note that even nominally numeric values remain encoded as strings.
622     """
623     self._simple("prefs", track)
624     r = {}
625     for line in self._body():
626       try:
627         kv = _split(line)
628       except _splitError, s:
629         raise protocolError(self.who, s.str())
630       if len(kv) != 2:
631         raise protocolError(self.who, "invalid prefs body line")
632       r[kv[0]] = kv[1]
633     return r
634
635   def _boolean(self, s):
636     return s[1] == 'yes'
637
638   def exists(self, track):
639     """Return true if a track exists
640
641     Arguments:
642     track -- the track to check for"""
643     return self._boolean(self._simple("exists", track))
644
645   def enabled(self):
646     """Return true if playing is enabled"""
647     return self._boolean(self._simple("enabled"))
648
649   def random_enabled(self):
650     """Return true if random play is enabled"""
651     return self._boolean(self._simple("random-enabled"))
652
653   def random_enable(self):
654     """Enable random play."""
655     self._simple("random-enable")
656
657   def random_disable(self):
658     """Disable random play."""
659     self._simple("random-disable")
660
661   def length(self, track):
662     """Return the length of a track in seconds.
663
664     Arguments:
665     track -- the track to query.
666     """
667     ret, details = self._simple("length", track)
668     return int(details)
669
670   def search(self, words):
671     """Search for tracks.
672
673     Arguments:
674     words -- the set of words to search for.
675
676     The return value is a list of track path names, all of which contain
677     all of the required words (in their path name, trackname
678     preferences, etc.)
679     """
680     self._simple("search", _quote(words))
681     return self._body()
682
683   def tags(self):
684     """List all tags
685
686     The return value is a list of all tags which apply to at least one
687     track."""
688     self._simple("tags")
689     return self._body()
690
691   def stats(self):
692     """Get server statistics.
693
694     The return value is list of statistics.
695     """
696     self._simple("stats")
697     return self._body()
698
699   def dump(self):
700     """Get all preferences.
701
702     The return value is an encoded dump of the preferences database.
703     """
704     self._simple("dump")
705     return self._body()
706
707   def set_volume(self, left, right):
708     """Set volume.
709
710     Arguments:
711     left -- volume for the left speaker.
712     right --  volume for the right speaker.
713     """
714     self._simple("volume", left, right)
715
716   def get_volume(self):
717     """Get volume.
718
719     The return value a tuple consisting of the left and right volumes.
720     """
721     ret, details = self._simple("volume")
722     return map(int,string.split(details))
723
724   def move(self, track, delta):
725     """Move a track in the queue.
726
727     Arguments:
728     track -- the name or ID of the track to move
729     delta -- the number of steps towards the head of the queue to move
730     """
731     ret, details = self._simple("move", track, str(delta))
732     return int(details)
733
734   def moveafter(self, target, tracks):
735     """Move a track in the queue
736
737     Arguments:
738     target -- target ID or None
739     tracks -- a list of IDs to move
740
741     If target is '' or is not in the queue then the tracks are moved to
742     the head of the queue.
743
744     Otherwise the tracks are moved to just after the target."""
745     if target is None:
746       target = ''
747     self._simple("moveafter", target, *tracks)
748
749   def log(self, callback):
750     """Read event log entries as they happen.
751
752     Each event log entry is handled by passing it to callback.
753
754     The callback takes two arguments, the first is the client and the
755     second the line from the event log.
756     
757     The callback should return True to continue or False to stop (don't
758     forget this, or your program will mysteriously misbehave).
759
760     It is suggested that you use the disorder.monitor class instead of
761     calling this method directly, but this is not mandatory.
762
763     See disorder_protocol(5) for the event log syntax.
764
765     Arguments:
766     callback -- function to call with log entry
767     """
768     ret, details = self._simple("log")
769     while True:
770       l = self._line()
771       self._debug(client.debug_body, "<<< %s" % l)
772       if l != '' and l[0] == '.':
773         if l == '.':
774           return
775         l = l[1:]
776       if not callback(self, l):
777         break
778     # tell the server to stop sending, eat the remains of the body,
779     # eat the response
780     self._send("version")
781     self._body()
782     self._response()
783
784   def pause(self):
785     """Pause the current track."""
786     self._simple("pause")
787
788   def resume(self):
789     """Resume after a pause."""
790     self._simple("resume")
791
792   def part(self, track, context, part):
793     """Get a track name part
794
795     Arguments:
796     track -- the track to query
797     context -- the context ('sort' or 'display')
798     part -- the desired part (usually 'artist', 'album' or 'title')
799
800     The return value is the preference 
801     """
802     ret, details = self._simple("part", track, context, part)
803     return details
804
805   def setglobal(self, key, value):
806     """Set a global preference value.
807
808     Arguments:
809     key -- the preference name
810     value -- the new preference value
811     """
812     self._simple("set-global", key, value)
813
814   def unsetglobal(self, key):
815     """Unset a global preference value.
816
817     Arguments:
818     key -- the preference to remove
819     """
820     self._simple("set-global", key, value)
821
822   def getglobal(self, key):
823     """Get a global preference value.
824
825     Arguments:
826     key -- the preference to look up
827
828     The return value is the preference 
829     """
830     ret, details = self._simple("get-global", key)
831     if ret == 555:
832       return None
833     else:
834       return details
835
836   ########################################################################
837   # I/O infrastructure
838
839   def _line(self):
840     # read one response line and return as some suitable string object
841     #
842     # If an I/O error occurs, disconnect from the server.
843     #
844     # XXX does readline() DTRT regarding character encodings?
845     try:
846       l = self.r.readline()
847       if not re.search("\n", l):
848         raise communicationError(self.who, "peer disconnected")
849       l = l[:-1]
850     except:
851       self._disconnect()
852       raise
853     return unicode(l, "UTF-8")
854
855   def _response(self):
856     # read a response as a (code, details) tuple
857     l = self._line()
858     self._debug(client.debug_proto, "<== %s" % l)
859     m = _response.match(l)
860     if m:
861       return int(m.group(1)), m.group(2)
862     else:
863       raise protocolError(self.who, "invalid response %s")
864
865   def _send(self, *command):
866     # Quote and send a command
867     #
868     # Returns the encoded command.
869     quoted = _quote(command)
870     self._debug(client.debug_proto, "==> %s" % quoted)
871     encoded = quoted.encode("UTF-8")
872     try:
873       self.w.write(encoded)
874       self.w.write("\n")
875       self.w.flush()
876       return encoded
877     except IOError, e:
878       # e.g. EPIPE
879       self._disconnect()
880       raise communicationError(self.who, e)
881     except:
882       self._disconnect()
883       raise
884
885   def _simple(self, *command):
886     # Issue a simple command, throw an exception on error
887     #
888     # If an I/O error occurs, disconnect from the server.
889     #
890     # On success or 'normal' errors returns response as a (code, details) tuple
891     #
892     # On error raise operationError
893     if self.state == 'disconnected':
894       self.connect()
895     if command:
896       cmd = self._send(*command)
897     else:
898       cmd = None
899     res, details = self._response()
900     if res / 100 == 2 or res == 555:
901       return res, details
902     raise operationError(res, details, cmd)
903
904   def _body(self):
905     # Fetch a dot-stuffed body
906     result = []
907     while True:
908       l = self._line()
909       self._debug(client.debug_body, "<<< %s" % l)
910       if l != '' and l[0] == '.':
911         if l == '.':
912           return result
913         l = l[1:]
914       result.append(l)
915
916   ########################################################################
917   # Configuration file parsing
918
919   def _readfile(self, path):
920     # Read a configuration file
921     #
922     # Arguments:
923     #
924     # path -- path of file to read
925
926     # handlers for various commands
927     def _collection(self, command, args):
928       if len(args) != 3:
929         return "'%s' takes three args" % command
930       self.config["collections"].append(args)
931       
932     def _unary(self, command, args):
933       if len(args) != 1:
934         return "'%s' takes only one arg" % command
935       self.config[command] = args[0]
936
937     def _include(self, command, args):
938       if len(args) != 1:
939         return "'%s' takes only one arg" % command
940       self._readfile(args[0])
941
942     def _any(self, command, args):
943       self.config[command] = args
944
945     # mapping of options to handlers
946     _options = { "collection": _collection,
947                  "username": _unary,
948                  "password": _unary,
949                  "home": _unary,
950                  "connect": _any,
951                  "include": _include }
952
953     # the parser
954     for lno, line in enumerate(file(path, "r")):
955       try:
956         fields = _split(line, 'comments')
957       except _splitError, s:
958         raise parseError(path, lno + 1, str(s))
959       if fields:
960         command = fields[0]
961         # we just ignore options we don't know about, so as to cope gracefully
962         # with version skew (and nothing to do with implementor laziness)
963         if command in _options:
964           e = _options[command](self, command, fields[1:])
965           if e:
966             self._parseError(path, lno + 1, e)
967
968   def _parseError(self, path, lno, s):
969     raise parseError(path, lno, s)
970
971 ########################################################################
972 # monitor class
973
974 class monitor:
975   """DisOrder event log monitor class
976
977   Intended to be subclassed with methods corresponding to event log messages
978   the implementor cares about over-ridden."""
979
980   def __init__(self, c=None):
981     """Constructor for the monitor class
982
983     Can be passed a client to use.  If none is specified then one
984     will be created specially for the purpose.
985
986     Arguments:
987     c -- client"""
988     if c == None:
989       c = client();
990     self.c = c
991
992   def run(self):
993     """Start monitoring logs.  Continues monitoring until one of the
994     message-specific methods returns False.  Can be called more than once
995     (but not recursively!)"""
996     self.c.log(self._callback)
997
998   def when(self):
999     """Return the timestamp of the current (or most recent) event log entry"""
1000     return self.timestamp
1001
1002   def _callback(self, c, line):
1003     try:
1004       bits = _split(line)
1005     except:
1006       return self.invalid(line)
1007     if(len(bits) < 2):
1008       return self.invalid(line)
1009     self.timestamp = int(bits[0], 16)
1010     keyword = bits[1]
1011     bits = bits[2:]
1012     if keyword == 'completed':
1013       if len(bits) == 1:
1014         return self.completed(bits[0])
1015     elif keyword == 'failed':
1016       if len(bits) == 2:
1017         return self.failed(bits[0], bits[1])
1018     elif keyword == 'moved':
1019       if len(bits) == 3:
1020         try:
1021           n = int(bits[1])
1022         except:
1023           return self.invalid(line)
1024         return self.moved(bits[0], n, bits[2])
1025     elif keyword == 'playing':
1026       if len(bits) == 1:
1027         return self.playing(bits[0], None)
1028       elif len(bits) == 2:
1029         return self.playing(bits[0], bits[1])
1030     elif keyword == 'queue' or keyword == 'recent-added':
1031       try:
1032         q = _list2dict(bits)
1033       except:
1034         return self.invalid(line)
1035       if keyword == 'queue':
1036         return self.queue(q)
1037       if keyword == 'recent-added':
1038         return self.recent_added(q)
1039     elif keyword == 'recent-removed':
1040       if len(bits) == 1:
1041         return self.recent_removed(bits[0])
1042     elif keyword == 'removed':
1043       if len(bits) == 1:
1044         return self.removed(bits[0], None)
1045       elif len(bits) == 2:
1046         return self.removed(bits[0], bits[1])
1047     elif keyword == 'scratched':
1048       if len(bits) == 2:
1049         return self.scratched(bits[0], bits[1])
1050     return self.invalid(line)
1051
1052   def completed(self, track):
1053     """Called when a track completes.
1054
1055     Arguments:
1056     track -- track that completed"""
1057     return True
1058
1059   def failed(self, track, error):
1060     """Called when a player suffers an error.
1061
1062     Arguments:
1063     track -- track that failed
1064     error -- error indicator"""
1065     return True
1066
1067   def moved(self, id, offset, user):
1068     """Called when a track is moved in the queue.
1069
1070     Arguments:
1071     id -- queue entry ID
1072     offset -- distance moved
1073     user -- user responsible"""
1074     return True
1075
1076   def playing(self, track, user):
1077     """Called when a track starts playing.
1078
1079     Arguments:
1080     track -- track that has started
1081     user -- user that submitted track, or None"""
1082     return True
1083
1084   def queue(self, q):
1085     """Called when a track is added to the queue.
1086
1087     Arguments:
1088     q -- dictionary of new queue entry"""
1089     return True
1090
1091   def recent_added(self, q):
1092     """Called when a track is added to the recently played list
1093
1094     Arguments:
1095     q -- dictionary of new queue entry"""
1096     return True
1097
1098   def recent_removed(self, id):
1099     """Called when a track is removed from the recently played list
1100
1101     Arguments:
1102     id -- ID of removed entry (always the oldest)"""
1103     return True
1104
1105   def removed(self, id, user):
1106     """Called when a track is removed from the queue, either manually
1107     or in order to play it.
1108
1109     Arguments:
1110     id -- ID of removed entry
1111     user -- user responsible (or None if we're playing this track)"""
1112     return True
1113
1114   def scratched(self, track, user):
1115     """Called when a track is scratched
1116
1117     Arguments:
1118     track -- track that was scratched
1119     user -- user responsible"""
1120     return True
1121
1122   def invalid(self, line):
1123     """Called when an event log line cannot be interpreted
1124
1125     Arguments:
1126     line -- line that could not be understood"""
1127     return True
1128
1129 # Local Variables:
1130 # mode:python
1131 # py-indent-offset:2
1132 # comment-column:40
1133 # fill-column:72
1134 # End: