]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
Add abstract unix socket support
[python-musicpd.git] / musicpd.py
1 # python-musicpd: Python MPD client library
2 # Copyright (C) 2012-2021  kaliko <kaliko@azylum.org>
3 # Copyright (C) 2019       Naglis Jonaitis <naglis@mailbox.org>
4 # Copyright (C) 2019       Bart Van Loon <bbb@bbbart.be>
5 # Copyright (C) 2008-2010  J. Alexander Treuman <jat@spatialrift.net>
6 #
7 # python-musicpd is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Lesser General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # python-musicpd is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Lesser General Public License for more details.
16 #
17 # You should have received a copy of the GNU Lesser General Public License
18 # along with python-musicpd.  If not, see <http://www.gnu.org/licenses/>.
19
20 # pylint: disable=missing-docstring
21
22 import socket
23 import os
24
25 from functools import wraps
26
27 HELLO_PREFIX = "OK MPD "
28 ERROR_PREFIX = "ACK "
29 SUCCESS = "OK"
30 NEXT = "list_OK"
31 VERSION = '0.6.0'
32 #: seconds before a tcp connection attempt times out (overriden by MPD_TIMEOUT env. var.)
33 CONNECTION_TIMEOUT = 30
34
35
36
37 def iterator_wrapper(func):
38     """Decorator handling iterate option"""
39     @wraps(func)
40     def decorated_function(instance, *args, **kwargs):
41         generator = func(instance, *args, **kwargs)
42         if not instance.iterate:
43             return list(generator)
44         instance._iterating = True
45
46         def iterator(gen):
47             try:
48                 for item in gen:
49                     yield item
50             finally:
51                 instance._iterating = False
52         return iterator(generator)
53     return decorated_function
54
55
56 class MPDError(Exception):
57     pass
58
59
60 class ConnectionError(MPDError):
61     pass
62
63
64 class ProtocolError(MPDError):
65     pass
66
67
68 class CommandError(MPDError):
69     pass
70
71
72 class CommandListError(MPDError):
73     pass
74
75
76 class PendingCommandError(MPDError):
77     pass
78
79
80 class IteratingError(MPDError):
81     pass
82
83
84 class Range:
85
86     def __init__(self, tpl):
87         self.tpl = tpl
88         self._check()
89
90     def __str__(self):
91         if len(self.tpl) == 0:
92             return ':'
93         if len(self.tpl) == 1:
94             return '{0}:'.format(self.tpl[0])
95         return '{0[0]}:{0[1]}'.format(self.tpl)
96
97     def __repr__(self):
98         return 'Range({0})'.format(self.tpl)
99
100     def _check(self):
101         if not isinstance(self.tpl, tuple):
102             raise CommandError('Wrong type, provide a tuple')
103         if len(self.tpl) not in [0, 1, 2]:
104             raise CommandError('length not in [0, 1, 2]')
105         for index in self.tpl:
106             try:
107                 index = int(index)
108             except (TypeError, ValueError):
109                 raise CommandError('Not a tuple of int')
110
111
112 class _NotConnected:
113
114     def __getattr__(self, attr):
115         return self._dummy
116
117     def _dummy(*args):
118         raise ConnectionError("Not connected")
119
120
121 class MPDClient:
122     """MPDClient instance will look for ``MPD_HOST``/``MPD_PORT``/``XDG_RUNTIME_DIR`` environment
123     variables and set instance attribute ``host``, ``port`` and ``pwd``
124     accordingly. Regarding ``MPD_HOST`` format to expose password refer
125     MPD client manual :manpage:`mpc (1)`.
126
127     Then :py:obj:`musicpd.MPDClient.connect` will use ``host`` and ``port`` as defaults if not provided as args.
128
129     Cf. :py:obj:`musicpd.MPDClient.connect` for details.
130
131     >>> from os import environ
132     >>> environ['MPD_HOST'] = 'pass@mpdhost'
133     >>> cli = musicpd.MPDClient()
134     >>> cli.pwd == environ['MPD_HOST'].split('@')[0]
135     True
136     >>> cli.host == environ['MPD_HOST'].split('@')[1]
137     True
138     >>> cli.connect() # will use host/port as set in MPD_HOST/MPD_PORT
139
140     :ivar str host: host used with the current connection
141     :ivar str,int port: port used with the current connection
142     :ivar str pwd: password detected in ``MPD_HOST`` environment variable
143
144     .. warning:: Instance attribute host/port/pwd
145
146       While :py:attr:`musicpd.MPDClient().host` and
147       :py:attr:`musicpd.MPDClient().port` keep track of current connection
148       host and port, :py:attr:`musicpd.MPDClient().pwd` is set once with
149       password extracted from environment variable.
150       Calling :py:meth:`musicpd.MPDClient().password()` with a new password
151       won't update :py:attr:`musicpd.MPDClient().pwd` value.
152
153       Moreover, :py:attr:`musicpd.MPDClient().pwd` is only an helper attribute
154       exposing password extracted from ``MPD_HOST`` environment variable, it
155       will not be used as default value for the :py:meth:`password` method
156     """
157
158     def __init__(self):
159         self.iterate = False
160         self._reset()
161         self._commands = {
162             # Status Commands
163             "clearerror":         self._fetch_nothing,
164             "currentsong":        self._fetch_object,
165             "idle":               self._fetch_list,
166             #"noidle":             None,
167             "status":             self._fetch_object,
168             "stats":              self._fetch_object,
169             # Playback Option Commands
170             "consume":            self._fetch_nothing,
171             "crossfade":          self._fetch_nothing,
172             "mixrampdb":          self._fetch_nothing,
173             "mixrampdelay":       self._fetch_nothing,
174             "random":             self._fetch_nothing,
175             "repeat":             self._fetch_nothing,
176             "setvol":             self._fetch_nothing,
177             "getvol":             self._fetch_object,
178             "single":             self._fetch_nothing,
179             "replay_gain_mode":   self._fetch_nothing,
180             "replay_gain_status": self._fetch_item,
181             "volume":             self._fetch_nothing,
182             # Playback Control Commands
183             "next":               self._fetch_nothing,
184             "pause":              self._fetch_nothing,
185             "play":               self._fetch_nothing,
186             "playid":             self._fetch_nothing,
187             "previous":           self._fetch_nothing,
188             "seek":               self._fetch_nothing,
189             "seekid":             self._fetch_nothing,
190             "seekcur":            self._fetch_nothing,
191             "stop":               self._fetch_nothing,
192             # Queue Commands
193             "add":                self._fetch_nothing,
194             "addid":              self._fetch_item,
195             "clear":              self._fetch_nothing,
196             "delete":             self._fetch_nothing,
197             "deleteid":           self._fetch_nothing,
198             "move":               self._fetch_nothing,
199             "moveid":             self._fetch_nothing,
200             "playlist":           self._fetch_playlist,
201             "playlistfind":       self._fetch_songs,
202             "playlistid":         self._fetch_songs,
203             "playlistinfo":       self._fetch_songs,
204             "playlistsearch":     self._fetch_songs,
205             "plchanges":          self._fetch_songs,
206             "plchangesposid":     self._fetch_changes,
207             "prio":               self._fetch_nothing,
208             "prioid":             self._fetch_nothing,
209             "rangeid":            self._fetch_nothing,
210             "shuffle":            self._fetch_nothing,
211             "swap":               self._fetch_nothing,
212             "swapid":             self._fetch_nothing,
213             "addtagid":           self._fetch_nothing,
214             "cleartagid":         self._fetch_nothing,
215             # Stored Playlist Commands
216             "listplaylist":       self._fetch_list,
217             "listplaylistinfo":   self._fetch_songs,
218             "listplaylists":      self._fetch_playlists,
219             "load":               self._fetch_nothing,
220             "playlistadd":        self._fetch_nothing,
221             "playlistclear":      self._fetch_nothing,
222             "playlistdelete":     self._fetch_nothing,
223             "playlistmove":       self._fetch_nothing,
224             "rename":             self._fetch_nothing,
225             "rm":                 self._fetch_nothing,
226             "save":               self._fetch_nothing,
227             # Database Commands
228             "albumart":           self._fetch_composite,
229             "count":              self._fetch_object,
230             "getfingerprint":     self._fetch_object,
231             "find":               self._fetch_songs,
232             "findadd":            self._fetch_nothing,
233             "list":               self._fetch_list,
234             "listall":            self._fetch_database,
235             "listallinfo":        self._fetch_database,
236             "listfiles":          self._fetch_database,
237             "lsinfo":             self._fetch_database,
238             "readcomments":       self._fetch_object,
239             "readpicture":        self._fetch_composite,
240             "search":             self._fetch_songs,
241             "searchadd":          self._fetch_nothing,
242             "searchaddpl":        self._fetch_nothing,
243             "update":             self._fetch_item,
244             "rescan":             self._fetch_item,
245             # Mounts and neighbors
246             "mount":              self._fetch_nothing,
247             "unmount":            self._fetch_nothing,
248             "listmounts":         self._fetch_mounts,
249             "listneighbors":      self._fetch_neighbors,
250             # Sticker Commands
251             "sticker get":        self._fetch_item,
252             "sticker set":        self._fetch_nothing,
253             "sticker delete":     self._fetch_nothing,
254             "sticker list":       self._fetch_list,
255             "sticker find":       self._fetch_songs,
256             # Connection Commands
257             "close":              None,
258             "kill":               None,
259             "password":           self._fetch_nothing,
260             "ping":               self._fetch_nothing,
261             "binarylimit":        self._fetch_nothing,
262             "tagtypes":           self._fetch_list,
263             "tagtypes disable":   self._fetch_nothing,
264             "tagtypes enable":    self._fetch_nothing,
265             "tagtypes clear":     self._fetch_nothing,
266             "tagtypes all":       self._fetch_nothing,
267             # Partition Commands
268             "partition":          self._fetch_nothing,
269             "listpartitions":     self._fetch_list,
270             "newpartition":       self._fetch_nothing,
271             "delpartition":       self._fetch_nothing,
272             "moveoutput":         self._fetch_nothing,
273             # Audio Output Commands
274             "disableoutput":      self._fetch_nothing,
275             "enableoutput":       self._fetch_nothing,
276             "toggleoutput":       self._fetch_nothing,
277             "outputs":            self._fetch_outputs,
278             "outputset":          self._fetch_nothing,
279             # Reflection Commands
280             "config":             self._fetch_object,
281             "commands":           self._fetch_list,
282             "notcommands":        self._fetch_list,
283             "urlhandlers":        self._fetch_list,
284             "decoders":           self._fetch_plugins,
285             # Client to Client
286             "subscribe":          self._fetch_nothing,
287             "unsubscribe":        self._fetch_nothing,
288             "channels":           self._fetch_list,
289             "readmessages":       self._fetch_messages,
290             "sendmessage":        self._fetch_nothing,
291         }
292         self._get_envvars()
293
294     def _get_envvars(self):
295         """
296         Retrieve MPD env. var. to overrides "localhost:6600"
297             Use MPD_HOST/MPD_PORT if set
298             else use MPD_HOST=${XDG_RUNTIME_DIR:-/run/}/mpd/socket if file exists
299         """
300         self.host = 'localhost'
301         self.pwd = None
302         self.port = os.getenv('MPD_PORT', '6600')
303         if os.getenv('MPD_HOST'):
304             # If password is set: MPD_HOST=pass@host
305             if '@' in os.getenv('MPD_HOST'):
306                 mpd_host_env = os.getenv('MPD_HOST').split('@', 1)
307                 if mpd_host_env[0]:
308                     # A password is actually set
309                     self.pwd = mpd_host_env[0]
310                     self.host = mpd_host_env[1]
311                 else:
312                     # No password set but leading @ is an abstract socket
313                     self.host = '@'+mpd_host_env[1]
314             else:
315                 # MPD_HOST is a plain host
316                 self.host = os.getenv('MPD_HOST')
317         else:
318             # Is socket there
319             xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
320             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
321             if os.path.exists(rundir):
322                 self.host = rundir
323         self.mpd_timeout = os.getenv('MPD_TIMEOUT')
324         if self.mpd_timeout and self.mpd_timeout.isdigit():
325             self.mpd_timeout = int(self.mpd_timeout)
326         else:  # Use 30s default even is MPD_TIMEOUT carries gargage
327             self.mpd_timeout = CONNECTION_TIMEOUT
328
329     def __getattr__(self, attr):
330         if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
331             return self.noidle()
332         if attr.startswith("send_"):
333             command = attr.replace("send_", "", 1)
334             wrapper = self._send
335         elif attr.startswith("fetch_"):
336             command = attr.replace("fetch_", "", 1)
337             wrapper = self._fetch
338         else:
339             command = attr
340             wrapper = self._execute
341         if command not in self._commands:
342             command = command.replace("_", " ")
343             if command not in self._commands:
344                 raise AttributeError("'%s' object has no attribute '%s'" %
345                                      (self.__class__.__name__, attr))
346         return lambda *args: wrapper(command, args)
347
348     def _send(self, command, args):
349         if self._command_list is not None:
350             raise CommandListError("Cannot use send_%s in a command list" %
351                                    command.replace(" ", "_"))
352         self._write_command(command, args)
353         retval = self._commands[command]
354         if retval is not None:
355             self._pending.append(command)
356
357     def _fetch(self, command, args=None):
358         if self._command_list is not None:
359             raise CommandListError("Cannot use fetch_%s in a command list" %
360                                    command.replace(" ", "_"))
361         if self._iterating:
362             raise IteratingError("Cannot use fetch_%s while iterating" %
363                                  command.replace(" ", "_"))
364         if not self._pending:
365             raise PendingCommandError("No pending commands to fetch")
366         if self._pending[0] != command:
367             raise PendingCommandError("'%s' is not the currently "
368                                       "pending command" % command)
369         del self._pending[0]
370         retval = self._commands[command]
371         if callable(retval):
372             return retval()
373         return retval
374
375     def _execute(self, command, args):
376         if self._iterating:
377             raise IteratingError("Cannot execute '%s' while iterating" %
378                                  command)
379         if self._pending:
380             raise PendingCommandError(
381                 "Cannot execute '%s' with pending commands" % command)
382         retval = self._commands[command]
383         if self._command_list is not None:
384             if not callable(retval):
385                 raise CommandListError(
386                     "'%s' not allowed in command list" % command)
387             self._write_command(command, args)
388             self._command_list.append(retval)
389         else:
390             self._write_command(command, args)
391             if callable(retval):
392                 return retval()
393             return retval
394
395     def _write_line(self, line):
396         self._wfile.write("%s\n" % line)
397         self._wfile.flush()
398
399     def _write_command(self, command, args=None):
400         if args is None:
401             args = []
402         parts = [command]
403         for arg in args:
404             if isinstance(arg, tuple):
405                 parts.append('{0!s}'.format(Range(arg)))
406             else:
407                 parts.append('"%s"' % escape(str(arg)))
408         self._write_line(" ".join(parts))
409
410     def _read_binary(self, amount):
411         chunk = bytearray()
412         while amount > 0:
413             result = self._rbfile.read(amount)
414             if len(result) == 0:
415                 self.disconnect()
416                 raise ConnectionError("Connection lost while reading binary content")
417             chunk.extend(result)
418             amount -= len(result)
419         return bytes(chunk)
420
421     def _read_line(self, binary=False):
422         if binary:
423             line = self._rbfile.readline().decode('utf-8')
424         else:
425             line = self._rfile.readline()
426         if not line.endswith("\n"):
427             self.disconnect()
428             raise ConnectionError("Connection lost while reading line")
429         line = line.rstrip("\n")
430         if line.startswith(ERROR_PREFIX):
431             error = line[len(ERROR_PREFIX):].strip()
432             raise CommandError(error)
433         if self._command_list is not None:
434             if line == NEXT:
435                 return
436             if line == SUCCESS:
437                 raise ProtocolError("Got unexpected '%s'" % SUCCESS)
438         elif line == SUCCESS:
439             return
440         return line
441
442     def _read_pair(self, separator, binary=False):
443         line = self._read_line(binary=binary)
444         if line is None:
445             return
446         pair = line.split(separator, 1)
447         if len(pair) < 2:
448             raise ProtocolError("Could not parse pair: '%s'" % line)
449         return pair
450
451     def _read_pairs(self, separator=": ", binary=False):
452         pair = self._read_pair(separator, binary=binary)
453         while pair:
454             yield pair
455             pair = self._read_pair(separator, binary=binary)
456
457     def _read_list(self):
458         seen = None
459         for key, value in self._read_pairs():
460             if key != seen:
461                 if seen is not None:
462                     raise ProtocolError("Expected key '%s', got '%s'" %
463                                         (seen, key))
464                 seen = key
465             yield value
466
467     def _read_playlist(self):
468         for _, value in self._read_pairs(":"):
469             yield value
470
471     def _read_objects(self, delimiters=None):
472         obj = {}
473         if delimiters is None:
474             delimiters = []
475         for key, value in self._read_pairs():
476             key = key.lower()
477             if obj:
478                 if key in delimiters:
479                     yield obj
480                     obj = {}
481                 elif key in obj:
482                     if not isinstance(obj[key], list):
483                         obj[key] = [obj[key], value]
484                     else:
485                         obj[key].append(value)
486                     continue
487             obj[key] = value
488         if obj:
489             yield obj
490
491     def _read_command_list(self):
492         try:
493             for retval in self._command_list:
494                 yield retval()
495         finally:
496             self._command_list = None
497         self._fetch_nothing()
498
499     def _fetch_nothing(self):
500         line = self._read_line()
501         if line is not None:
502             raise ProtocolError("Got unexpected return value: '%s'" % line)
503
504     def _fetch_item(self):
505         pairs = list(self._read_pairs())
506         if len(pairs) != 1:
507             return
508         return pairs[0][1]
509
510     @iterator_wrapper
511     def _fetch_list(self):
512         return self._read_list()
513
514     @iterator_wrapper
515     def _fetch_playlist(self):
516         return self._read_playlist()
517
518     def _fetch_object(self):
519         objs = list(self._read_objects())
520         if not objs:
521             return {}
522         return objs[0]
523
524     @iterator_wrapper
525     def _fetch_objects(self, delimiters):
526         return self._read_objects(delimiters)
527
528     def _fetch_changes(self):
529         return self._fetch_objects(["cpos"])
530
531     def _fetch_songs(self):
532         return self._fetch_objects(["file"])
533
534     def _fetch_playlists(self):
535         return self._fetch_objects(["playlist"])
536
537     def _fetch_database(self):
538         return self._fetch_objects(["file", "directory", "playlist"])
539
540     def _fetch_outputs(self):
541         return self._fetch_objects(["outputid"])
542
543     def _fetch_plugins(self):
544         return self._fetch_objects(["plugin"])
545
546     def _fetch_messages(self):
547         return self._fetch_objects(["channel"])
548
549     def _fetch_mounts(self):
550         return self._fetch_objects(["mount"])
551
552     def _fetch_neighbors(self):
553         return self._fetch_objects(["neighbor"])
554
555     def _fetch_composite(self):
556         obj = {}
557         for key, value in self._read_pairs(binary=True):
558             key = key.lower()
559             obj[key] = value
560             if key == 'binary':
561                 break
562         if not obj:
563             # If the song file was recognized, but there is no picture, the
564             # response is successful, but is otherwise empty.
565             return obj
566         amount = int(obj['binary'])
567         try:
568             obj['data'] = self._read_binary(amount)
569         except IOError as err:
570             raise ConnectionError('Error reading binary content: %s' % err)
571         if len(obj['data']) != amount:
572             raise ConnectionError('Error reading binary content: '
573                       'Expects %sB, got %s' % (amount, len(obj['data'])))
574         # Fetches trailing new line
575         self._read_line(binary=True)
576         # Fetches SUCCESS code
577         self._read_line(binary=True)
578         return obj
579
580     @iterator_wrapper
581     def _fetch_command_list(self):
582         return self._read_command_list()
583
584     def _hello(self):
585         line = self._rfile.readline()
586         if not line.endswith("\n"):
587             raise ConnectionError("Connection lost while reading MPD hello")
588         line = line.rstrip("\n")
589         if not line.startswith(HELLO_PREFIX):
590             raise ProtocolError("Got invalid MPD hello: '%s'" % line)
591         self.mpd_version = line[len(HELLO_PREFIX):].strip()
592
593     def _reset(self):
594         self.mpd_version = None
595         self._iterating = False
596         self._pending = []
597         self._command_list = None
598         self._sock = None
599         self._rfile = _NotConnected()
600         self._rbfile = _NotConnected()
601         self._wfile = _NotConnected()
602
603     def _connect_unix(self, path):
604         if not hasattr(socket, "AF_UNIX"):
605             raise ConnectionError(
606                 "Unix domain sockets not supported on this platform")
607         # abstract socket
608         if path.startswith('@'):
609             path = '\0'+path[1:]
610         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
611         sock.connect(path)
612         return sock
613
614     def _connect_tcp(self, host, port):
615         try:
616             flags = socket.AI_ADDRCONFIG
617         except AttributeError:
618             flags = 0
619         err = None
620         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
621                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
622                                       flags):
623             af, socktype, proto, _, sa = res
624             sock = None
625             try:
626                 sock = socket.socket(af, socktype, proto)
627                 sock.settimeout(self.mpd_timeout)
628                 sock.connect(sa)
629                 sock.settimeout(None)
630                 return sock
631             except socket.error as socket_err:
632                 err = socket_err
633                 if sock is not None:
634                     sock.close()
635         if err is not None:
636             raise ConnectionError(str(err))
637         else:
638             raise ConnectionError("getaddrinfo returns an empty list")
639
640     def noidle(self):
641         # noidle's special case
642         if not self._pending or self._pending[0] != 'idle':
643             raise CommandError(
644                 'cannot send noidle if send_idle was not called')
645         del self._pending[0]
646         self._write_command("noidle")
647         return self._fetch_list()
648
649     def connect(self, host=None, port=None):
650         """Connects the MPD server
651
652         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
653         :param port: port number (defaults to 6600)
654         :type port: str or int
655
656         The connect method honors MPD_HOST/MPD_PORT environment variables.
657
658         The underlying tcp socket also honors MPD_TIMEOUT environment variable
659         and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT`.
660
661         .. note:: Default host/port
662
663           If host evaluate to :py:obj:`False`
664            * use ``MPD_HOST`` environment variable if set, extract password if present,
665            * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
666            * else set host to ``localhost``
667
668           If port evaluate to :py:obj:`False`
669            * if ``MPD_PORT`` environment variable is set, use it for port
670            * else use ``6600``
671         """
672         if not host:
673             host = self.host
674         else:
675             self.host = host
676         if not port:
677             port = self.port
678         else:
679             self.port = port
680         if self._sock is not None:
681             raise ConnectionError("Already connected")
682         if host[0] in ['/', '@']:
683             self._sock = self._connect_unix(host)
684         else:
685             self._sock = self._connect_tcp(host, port)
686         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
687         self._rbfile = self._sock.makefile("rb")
688         self._wfile = self._sock.makefile("w", encoding='utf-8')
689         try:
690             self._hello()
691         except:
692             self.disconnect()
693             raise
694
695     def disconnect(self):
696         """Closes the MPD connection.
697         The client closes the actual socket, it does not use the
698         'close' request from MPD protocol (as suggested in documentation).
699         """
700         if hasattr(self._rfile, 'close'):
701             self._rfile.close()
702         if hasattr(self._rbfile, 'close'):
703             self._rbfile.close()
704         if hasattr(self._wfile, 'close'):
705             self._wfile.close()
706         if hasattr(self._sock, 'close'):
707             self._sock.close()
708         self._reset()
709
710     def fileno(self):
711         if self._sock is None:
712             raise ConnectionError("Not connected")
713         return self._sock.fileno()
714
715     def command_list_ok_begin(self):
716         if self._command_list is not None:
717             raise CommandListError("Already in command list")
718         if self._iterating:
719             raise IteratingError("Cannot begin command list while iterating")
720         if self._pending:
721             raise PendingCommandError("Cannot begin command list "
722                                       "with pending commands")
723         self._write_command("command_list_ok_begin")
724         self._command_list = []
725
726     def command_list_end(self):
727         if self._command_list is None:
728             raise CommandListError("Not in command list")
729         if self._iterating:
730             raise IteratingError("Already iterating over a command list")
731         self._write_command("command_list_end")
732         return self._fetch_command_list()
733
734
735 def escape(text):
736     return text.replace("\\", "\\\\").replace('"', '\\"')
737
738 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: