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