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