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