]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
Honor MPD_TIMEOUT environment variables (closes #11)
[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         mpd_host_env = os.getenv('MPD_HOST')
304         if mpd_host_env:
305             # If password is set:
306             # mpd_host_env = ['pass', 'host'] because MPD_HOST=pass@host
307             mpd_host_env = mpd_host_env.split('@')
308             mpd_host_env.reverse()
309             self.host = mpd_host_env[0]
310             if len(mpd_host_env) > 1 and mpd_host_env[1]:
311                 self.pwd = mpd_host_env[1]
312         else:
313             # Is socket there
314             xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
315             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
316             if os.path.exists(rundir):
317                 self.host = rundir
318         self.mpd_timeout = os.getenv('MPD_TIMEOUT')
319         if self.mpd_timeout and self.mpd_timeout.isdigit():
320             self.mpd_timeout = int(self.mpd_timeout)
321         else:  # Use 30s default even is MPD_TIMEOUT carries gargage
322             self.mpd_timeout = CONNECTION_TIMEOUT
323
324     def __getattr__(self, attr):
325         if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
326             return self.noidle()
327         if attr.startswith("send_"):
328             command = attr.replace("send_", "", 1)
329             wrapper = self._send
330         elif attr.startswith("fetch_"):
331             command = attr.replace("fetch_", "", 1)
332             wrapper = self._fetch
333         else:
334             command = attr
335             wrapper = self._execute
336         if command not in self._commands:
337             command = command.replace("_", " ")
338             if command not in self._commands:
339                 raise AttributeError("'%s' object has no attribute '%s'" %
340                                      (self.__class__.__name__, attr))
341         return lambda *args: wrapper(command, args)
342
343     def _send(self, command, args):
344         if self._command_list is not None:
345             raise CommandListError("Cannot use send_%s in a command list" %
346                                    command.replace(" ", "_"))
347         self._write_command(command, args)
348         retval = self._commands[command]
349         if retval is not None:
350             self._pending.append(command)
351
352     def _fetch(self, command, args=None):
353         if self._command_list is not None:
354             raise CommandListError("Cannot use fetch_%s in a command list" %
355                                    command.replace(" ", "_"))
356         if self._iterating:
357             raise IteratingError("Cannot use fetch_%s while iterating" %
358                                  command.replace(" ", "_"))
359         if not self._pending:
360             raise PendingCommandError("No pending commands to fetch")
361         if self._pending[0] != command:
362             raise PendingCommandError("'%s' is not the currently "
363                                       "pending command" % command)
364         del self._pending[0]
365         retval = self._commands[command]
366         if callable(retval):
367             return retval()
368         return retval
369
370     def _execute(self, command, args):
371         if self._iterating:
372             raise IteratingError("Cannot execute '%s' while iterating" %
373                                  command)
374         if self._pending:
375             raise PendingCommandError(
376                 "Cannot execute '%s' with pending commands" % command)
377         retval = self._commands[command]
378         if self._command_list is not None:
379             if not callable(retval):
380                 raise CommandListError(
381                     "'%s' not allowed in command list" % command)
382             self._write_command(command, args)
383             self._command_list.append(retval)
384         else:
385             self._write_command(command, args)
386             if callable(retval):
387                 return retval()
388             return retval
389
390     def _write_line(self, line):
391         self._wfile.write("%s\n" % line)
392         self._wfile.flush()
393
394     def _write_command(self, command, args=None):
395         if args is None:
396             args = []
397         parts = [command]
398         for arg in args:
399             if isinstance(arg, tuple):
400                 parts.append('{0!s}'.format(Range(arg)))
401             else:
402                 parts.append('"%s"' % escape(str(arg)))
403         self._write_line(" ".join(parts))
404
405     def _read_binary(self, amount):
406         chunk = bytearray()
407         while amount > 0:
408             result = self._rbfile.read(amount)
409             if len(result) == 0:
410                 self.disconnect()
411                 raise ConnectionError("Connection lost while reading binary content")
412             chunk.extend(result)
413             amount -= len(result)
414         return bytes(chunk)
415
416     def _read_line(self, binary=False):
417         if binary:
418             line = self._rbfile.readline().decode('utf-8')
419         else:
420             line = self._rfile.readline()
421         if not line.endswith("\n"):
422             self.disconnect()
423             raise ConnectionError("Connection lost while reading line")
424         line = line.rstrip("\n")
425         if line.startswith(ERROR_PREFIX):
426             error = line[len(ERROR_PREFIX):].strip()
427             raise CommandError(error)
428         if self._command_list is not None:
429             if line == NEXT:
430                 return
431             if line == SUCCESS:
432                 raise ProtocolError("Got unexpected '%s'" % SUCCESS)
433         elif line == SUCCESS:
434             return
435         return line
436
437     def _read_pair(self, separator, binary=False):
438         line = self._read_line(binary=binary)
439         if line is None:
440             return
441         pair = line.split(separator, 1)
442         if len(pair) < 2:
443             raise ProtocolError("Could not parse pair: '%s'" % line)
444         return pair
445
446     def _read_pairs(self, separator=": ", binary=False):
447         pair = self._read_pair(separator, binary=binary)
448         while pair:
449             yield pair
450             pair = self._read_pair(separator, binary=binary)
451
452     def _read_list(self):
453         seen = None
454         for key, value in self._read_pairs():
455             if key != seen:
456                 if seen is not None:
457                     raise ProtocolError("Expected key '%s', got '%s'" %
458                                         (seen, key))
459                 seen = key
460             yield value
461
462     def _read_playlist(self):
463         for _, value in self._read_pairs(":"):
464             yield value
465
466     def _read_objects(self, delimiters=None):
467         obj = {}
468         if delimiters is None:
469             delimiters = []
470         for key, value in self._read_pairs():
471             key = key.lower()
472             if obj:
473                 if key in delimiters:
474                     yield obj
475                     obj = {}
476                 elif key in obj:
477                     if not isinstance(obj[key], list):
478                         obj[key] = [obj[key], value]
479                     else:
480                         obj[key].append(value)
481                     continue
482             obj[key] = value
483         if obj:
484             yield obj
485
486     def _read_command_list(self):
487         try:
488             for retval in self._command_list:
489                 yield retval()
490         finally:
491             self._command_list = None
492         self._fetch_nothing()
493
494     def _fetch_nothing(self):
495         line = self._read_line()
496         if line is not None:
497             raise ProtocolError("Got unexpected return value: '%s'" % line)
498
499     def _fetch_item(self):
500         pairs = list(self._read_pairs())
501         if len(pairs) != 1:
502             return
503         return pairs[0][1]
504
505     @iterator_wrapper
506     def _fetch_list(self):
507         return self._read_list()
508
509     @iterator_wrapper
510     def _fetch_playlist(self):
511         return self._read_playlist()
512
513     def _fetch_object(self):
514         objs = list(self._read_objects())
515         if not objs:
516             return {}
517         return objs[0]
518
519     @iterator_wrapper
520     def _fetch_objects(self, delimiters):
521         return self._read_objects(delimiters)
522
523     def _fetch_changes(self):
524         return self._fetch_objects(["cpos"])
525
526     def _fetch_songs(self):
527         return self._fetch_objects(["file"])
528
529     def _fetch_playlists(self):
530         return self._fetch_objects(["playlist"])
531
532     def _fetch_database(self):
533         return self._fetch_objects(["file", "directory", "playlist"])
534
535     def _fetch_outputs(self):
536         return self._fetch_objects(["outputid"])
537
538     def _fetch_plugins(self):
539         return self._fetch_objects(["plugin"])
540
541     def _fetch_messages(self):
542         return self._fetch_objects(["channel"])
543
544     def _fetch_mounts(self):
545         return self._fetch_objects(["mount"])
546
547     def _fetch_neighbors(self):
548         return self._fetch_objects(["neighbor"])
549
550     def _fetch_composite(self):
551         obj = {}
552         for key, value in self._read_pairs(binary=True):
553             key = key.lower()
554             obj[key] = value
555             if key == 'binary':
556                 break
557         if not obj:
558             # If the song file was recognized, but there is no picture, the
559             # response is successful, but is otherwise empty.
560             return obj
561         amount = int(obj['binary'])
562         try:
563             obj['data'] = self._read_binary(amount)
564         except IOError as err:
565             raise ConnectionError('Error reading binary content: %s' % err)
566         if len(obj['data']) != amount:
567             raise ConnectionError('Error reading binary content: '
568                       'Expects %sB, got %s' % (amount, len(obj['data'])))
569         # Fetches trailing new line
570         self._read_line(binary=True)
571         # Fetches SUCCESS code
572         self._read_line(binary=True)
573         return obj
574
575     @iterator_wrapper
576     def _fetch_command_list(self):
577         return self._read_command_list()
578
579     def _hello(self):
580         line = self._rfile.readline()
581         if not line.endswith("\n"):
582             raise ConnectionError("Connection lost while reading MPD hello")
583         line = line.rstrip("\n")
584         if not line.startswith(HELLO_PREFIX):
585             raise ProtocolError("Got invalid MPD hello: '%s'" % line)
586         self.mpd_version = line[len(HELLO_PREFIX):].strip()
587
588     def _reset(self):
589         self.mpd_version = None
590         self._iterating = False
591         self._pending = []
592         self._command_list = None
593         self._sock = None
594         self._rfile = _NotConnected()
595         self._rbfile = _NotConnected()
596         self._wfile = _NotConnected()
597
598     def _connect_unix(self, path):
599         if not hasattr(socket, "AF_UNIX"):
600             raise ConnectionError(
601                 "Unix domain sockets not supported on this platform")
602         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
603         sock.connect(path)
604         return sock
605
606     def _connect_tcp(self, host, port):
607         try:
608             flags = socket.AI_ADDRCONFIG
609         except AttributeError:
610             flags = 0
611         err = None
612         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
613                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
614                                       flags):
615             af, socktype, proto, _, sa = res
616             sock = None
617             try:
618                 sock = socket.socket(af, socktype, proto)
619                 sock.settimeout(self.mpd_timeout)
620                 sock.connect(sa)
621                 sock.settimeout(None)
622                 return sock
623             except socket.error as socket_err:
624                 err = socket_err
625                 if sock is not None:
626                     sock.close()
627         if err is not None:
628             raise ConnectionError(str(err))
629         else:
630             raise ConnectionError("getaddrinfo returns an empty list")
631
632     def noidle(self):
633         # noidle's special case
634         if not self._pending or self._pending[0] != 'idle':
635             raise CommandError(
636                 'cannot send noidle if send_idle was not called')
637         del self._pending[0]
638         self._write_command("noidle")
639         return self._fetch_list()
640
641     def connect(self, host=None, port=None):
642         """Connects the MPD server
643
644         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
645         :param port: port number (defaults to 6600)
646         :type port: str or int
647
648         The connect method honors MPD_HOST/MPD_PORT environment variables.
649
650         The underlying tcp socket also honors MPD_TIMEOUT environment variable
651         and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT`.
652
653         .. note:: Default host/port
654
655           If host evaluate to :py:obj:`False`
656            * use ``MPD_HOST`` environment variable if set, extract password if present,
657            * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
658            * else set host to ``localhost``
659
660           If port evaluate to :py:obj:`False`
661            * if ``MPD_PORT`` environment variable is set, use it for port
662            * else use ``6600``
663         """
664         if not host:
665             host = self.host
666         else:
667             self.host = host
668         if not port:
669             port = self.port
670         else:
671             self.port = port
672         if self._sock is not None:
673             raise ConnectionError("Already connected")
674         if host.startswith("/"):
675             self._sock = self._connect_unix(host)
676         else:
677             self._sock = self._connect_tcp(host, port)
678         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
679         self._rbfile = self._sock.makefile("rb")
680         self._wfile = self._sock.makefile("w", encoding='utf-8')
681         try:
682             self._hello()
683         except:
684             self.disconnect()
685             raise
686
687     def disconnect(self):
688         """Closes the MPD connection.
689         The client closes the actual socket, it does not use the
690         'close' request from MPD protocol (as suggested in documentation).
691         """
692         if hasattr(self._rfile, 'close'):
693             self._rfile.close()
694         if hasattr(self._rbfile, 'close'):
695             self._rbfile.close()
696         if hasattr(self._wfile, 'close'):
697             self._wfile.close()
698         if hasattr(self._sock, 'close'):
699             self._sock.close()
700         self._reset()
701
702     def fileno(self):
703         if self._sock is None:
704             raise ConnectionError("Not connected")
705         return self._sock.fileno()
706
707     def command_list_ok_begin(self):
708         if self._command_list is not None:
709             raise CommandListError("Already in command list")
710         if self._iterating:
711             raise IteratingError("Cannot begin command list while iterating")
712         if self._pending:
713             raise PendingCommandError("Cannot begin command list "
714                                       "with pending commands")
715         self._write_command("command_list_ok_begin")
716         self._command_list = []
717
718     def command_list_end(self):
719         if self._command_list is None:
720             raise CommandListError("Not in command list")
721         if self._iterating:
722             raise IteratingError("Already iterating over a command list")
723         self._write_command("command_list_end")
724         return self._fetch_command_list()
725
726
727 def escape(text):
728     return text.replace("\\", "\\\\").replace('"', '\\"')
729
730 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: