X-Git-Url: http://git.kaliko.me/?a=blobdiff_plain;f=musicpd.py;h=e779355ae24bf9fa00a49729e055e2ba0dee2f3f;hb=c353490d8fac7cb3f17193b1b2e3b9bb191d47ce;hp=9b1d51b7477b565a0d72a0aa84199e31f741f063;hpb=a3420f074de70e8cd6f3050e3351fa9b43db0102;p=python-musicpd.git diff --git a/musicpd.py b/musicpd.py index 9b1d51b..e779355 100644 --- a/musicpd.py +++ b/musicpd.py @@ -1,37 +1,30 @@ -# python-musicpd: Python MPD client library -# Copyright (C) 2012-2021 kaliko -# Copyright (C) 2019 Naglis Jonaitis -# Copyright (C) 2019 Bart Van Loon -# Copyright (C) 2008-2010 J. Alexander Treuman -# -# python-musicpd is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# python-musicpd is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with python-musicpd. If not, see . - -# pylint: disable=missing-docstring +# SPDX-FileCopyrightText: 2012-2023 kaliko +# SPDX-FileCopyrightText: 2021 Wonko der Verständige +# SPDX-FileCopyrightText: 2019 Naglis Jonaitis +# SPDX-FileCopyrightText: 2019 Bart Van Loon +# SPDX-FileCopyrightText: 2008-2010 J. Alexander Treuman +# SPDX-License-Identifier: LGPL-3.0-or-later +"""python-musicpd: Python Music Player Daemon client library""" + import socket import os from functools import wraps +# Type hint for python <= 3.8 +from typing import Any, Dict, List, Tuple +from typing import Optional, Union HELLO_PREFIX = "OK MPD " ERROR_PREFIX = "ACK " SUCCESS = "OK" NEXT = "list_OK" -VERSION = '0.6.0' -#: seconds before a tcp connection attempt times out (overriden by MPD_TIMEOUT env. var.) -CONNECTION_TIMEOUT = 30 - +VERSION = '0.9.0b0' +#: Seconds before a connection attempt times out +#: (overriden by MPD_TIMEOUT env. var.) +CONNECTION_TIMEOUT: int = 30 +#: Socket timeout in second (Default is None for no timeout) +SOCKET_TIMEOUT: Union[int, None] = None def iterator_wrapper(func): @@ -83,7 +76,7 @@ class IteratingError(MPDError): class Range: - def __init__(self, tpl): + def __init__(self, tpl: Tuple[int]): self.tpl = tpl self._check() @@ -105,8 +98,8 @@ class Range: for index in self.tpl: try: index = int(index) - except (TypeError, ValueError): - raise CommandError('Not a tuple of int') + except (TypeError, ValueError) as err: + raise CommandError('Not a tuple of int') from err class _NotConnected: @@ -114,7 +107,7 @@ class _NotConnected: def __getattr__(self, attr): return self._dummy - def _dummy(*args): + def _dummy(self, *args): raise ConnectionError("Not connected") @@ -155,8 +148,15 @@ class MPDClient: will not be used as default value for the :py:meth:`password` method """ - def __init__(self): - self.iterate = False + def __init__(self) -> None: + self.iterate: bool = False + #: Socket timeout value in seconds + self._socket_timeout = SOCKET_TIMEOUT + #: Current connection timeout value, defaults to + #: :py:obj:`CONNECTION_TIMEOUT` or env. var. ``MPD_TIMEOUT`` if provided + self.mpd_timeout: Union[None,int] = None + #: Protocol version as exposed by the server + self.mpd_version: str = '' self._reset() self._commands = { # Status Commands @@ -291,19 +291,20 @@ class MPDClient: } self._get_envvars() - def _get_envvars(self): + def _get_envvars(self) -> None: """ Retrieve MPD env. var. to overrides "localhost:6600" Use MPD_HOST/MPD_PORT if set else use MPD_HOST=${XDG_RUNTIME_DIR:-/run/}/mpd/socket if file exists """ - self.host = 'localhost' - self.pwd = None - self.port = os.getenv('MPD_PORT', '6600') - if os.getenv('MPD_HOST'): + self.host: str = 'localhost' + self.pwd: Union[None, str] = None + self.port: Union[int,str] = os.getenv('MPD_PORT', '6600') + _host: str = os.getenv('MPD_HOST', '') + if _host: # If password is set: MPD_HOST=pass@host - if '@' in os.getenv('MPD_HOST'): - mpd_host_env = os.getenv('MPD_HOST').split('@', 1) + if '@' in _host: + mpd_host_env = _host.split('@', 1) if mpd_host_env[0]: # A password is actually set self.pwd = mpd_host_env[0] @@ -314,17 +315,17 @@ class MPDClient: self.host = '@'+mpd_host_env[1] else: # MPD_HOST is a plain host - self.host = os.getenv('MPD_HOST') + self.host = _host else: # Is socket there xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run') rundir = os.path.join(xdg_runtime_dir, 'mpd/socket') if os.path.exists(rundir): self.host = rundir - self.mpd_timeout = os.getenv('MPD_TIMEOUT') - if self.mpd_timeout and self.mpd_timeout.isdigit(): - self.mpd_timeout = int(self.mpd_timeout) - else: # Use 30s default even is MPD_TIMEOUT carries gargage + _mpd_timeout = os.getenv('MPD_TIMEOUT', 'X') + if _mpd_timeout.isdigit(): + self.mpd_timeout = int(_mpd_timeout) + else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage self.mpd_timeout = CONNECTION_TIMEOUT def __getattr__(self, attr): @@ -342,8 +343,8 @@ class MPDClient: if command not in self._commands: command = command.replace("_", " ") if command not in self._commands: - raise AttributeError("'%s' object has no attribute '%s'" % - (self.__class__.__name__, attr)) + cls = self.__class__.__name__ + raise AttributeError(f"'{cls}' object has no attribute '{attr}'") return lambda *args: wrapper(command, args) def _send(self, command, args): @@ -355,36 +356,31 @@ class MPDClient: if retval is not None: self._pending.append(command) - def _fetch(self, command, args=None): + def _fetch(self, command, args=None): # pylint: disable=unused-argument + cmd_fmt = command.replace(" ", "_") if self._command_list is not None: - raise CommandListError("Cannot use fetch_%s in a command list" % - command.replace(" ", "_")) + raise CommandListError(f"Cannot use fetch_{cmd_fmt} in a command list") if self._iterating: - raise IteratingError("Cannot use fetch_%s while iterating" % - command.replace(" ", "_")) + raise IteratingError(f"Cannot use fetch_{cmd_fmt} while iterating") if not self._pending: raise PendingCommandError("No pending commands to fetch") if self._pending[0] != command: - raise PendingCommandError("'%s' is not the currently " - "pending command" % command) + raise PendingCommandError(f"'{command}' is not the currently pending command") del self._pending[0] retval = self._commands[command] if callable(retval): return retval() return retval - def _execute(self, command, args): + def _execute(self, command, args): # pylint: disable=unused-argument if self._iterating: - raise IteratingError("Cannot execute '%s' while iterating" % - command) + raise IteratingError(f"Cannot execute '{command}' while iterating") if self._pending: - raise PendingCommandError( - "Cannot execute '%s' with pending commands" % command) + raise PendingCommandError(f"Cannot execute '{command}' with pending commands") retval = self._commands[command] if self._command_list is not None: if not callable(retval): - raise CommandListError( - "'%s' not allowed in command list" % command) + raise CommandListError(f"'{command}' not allowed in command list") self._write_command(command, args) self._command_list.append(retval) else: @@ -392,9 +388,10 @@ class MPDClient: if callable(retval): return retval() return retval + return None def _write_line(self, line): - self._wfile.write("%s\n" % line) + self._wfile.write(f"{line!s}\n") self._wfile.flush() def _write_command(self, command, args=None): @@ -406,6 +403,8 @@ class MPDClient: parts.append('{0!s}'.format(Range(arg))) else: parts.append('"%s"' % escape(str(arg))) + if '\n' in ' '.join(parts): + raise CommandError('new line found in the command!') self._write_line(" ".join(parts)) def _read_binary(self, amount): @@ -419,7 +418,7 @@ class MPDClient: amount -= len(result) return bytes(chunk) - def _read_line(self, binary=False): + def _read_line(self, binary: bool = False): if binary: line = self._rbfile.readline().decode('utf-8') else: @@ -433,23 +432,23 @@ class MPDClient: raise CommandError(error) if self._command_list is not None: if line == NEXT: - return + return None if line == SUCCESS: - raise ProtocolError("Got unexpected '%s'" % SUCCESS) + raise ProtocolError(f"Got unexpected '{SUCCESS}'") elif line == SUCCESS: - return + return None return line - def _read_pair(self, separator, binary=False): + def _read_pair(self, separator: str, binary: bool = False): line = self._read_line(binary=binary) if line is None: - return + return None pair = line.split(separator, 1) if len(pair) < 2: - raise ProtocolError("Could not parse pair: '%s'" % line) + raise ProtocolError(f"Could not parse pair: '{line}'") return pair - def _read_pairs(self, separator=": ", binary=False): + def _read_pairs(self, separator=": ", binary: bool =False): pair = self._read_pair(separator, binary=binary) while pair: yield pair @@ -460,8 +459,7 @@ class MPDClient: for key, value in self._read_pairs(): if key != seen: if seen is not None: - raise ProtocolError("Expected key '%s', got '%s'" % - (seen, key)) + raise ProtocolError(f"Expected key '{seen}', got '{key}'") seen = key yield value @@ -469,8 +467,8 @@ class MPDClient: for _, value in self._read_pairs(":"): yield value - def _read_objects(self, delimiters=None): - obj = {} + def _read_objects(self, delimiters: Optional[List[str]] = None): + obj: Dict[str,Any] = {} if delimiters is None: delimiters = [] for key, value in self._read_pairs(): @@ -500,12 +498,12 @@ class MPDClient: def _fetch_nothing(self): line = self._read_line() if line is not None: - raise ProtocolError("Got unexpected return value: '%s'" % line) + raise ProtocolError(f"Got unexpected return value: '{line}'") def _fetch_item(self): pairs = list(self._read_pairs()) if len(pairs) != 1: - return + return None return pairs[0][1] @iterator_wrapper @@ -568,10 +566,11 @@ class MPDClient: try: obj['data'] = self._read_binary(amount) except IOError as err: - raise ConnectionError('Error reading binary content: %s' % err) - if len(obj['data']) != amount: + raise ConnectionError(f'Error reading binary content: {err}') from err + data_bytes = len(obj['data']) + if data_bytes != amount: # can we ever get there? raise ConnectionError('Error reading binary content: ' - 'Expects %sB, got %s' % (amount, len(obj['data']))) + f'Expects {amount}B, got {data_bytes}') # Fetches trailing new line self._read_line(binary=True) # Fetches SUCCESS code @@ -588,11 +587,11 @@ class MPDClient: raise ConnectionError("Connection lost while reading MPD hello") line = line.rstrip("\n") if not line.startswith(HELLO_PREFIX): - raise ProtocolError("Got invalid MPD hello: '%s'" % line) + raise ProtocolError(f"Got invalid MPD hello: '{line}'") self.mpd_version = line[len(HELLO_PREFIX):].strip() def _reset(self): - self.mpd_version = None + self.mpd_version = '' self._iterating = False self._pending = [] self._command_list = None @@ -603,13 +602,14 @@ class MPDClient: def _connect_unix(self, path): if not hasattr(socket, "AF_UNIX"): - raise ConnectionError( - "Unix domain sockets not supported on this platform") + raise ConnectionError("Unix domain sockets not supported on this platform") # abstract socket if path.startswith('@'): path = '\0'+path[1:] sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.mpd_timeout) sock.connect(path) + sock.settimeout(self.socket_timeout) return sock def _connect_tcp(self, host, port): @@ -627,7 +627,7 @@ class MPDClient: sock = socket.socket(af, socktype, proto) sock.settimeout(self.mpd_timeout) sock.connect(sa) - sock.settimeout(None) + sock.settimeout(self.socket_timeout) return sock except socket.error as socket_err: err = socket_err @@ -635,19 +635,17 @@ class MPDClient: sock.close() if err is not None: raise ConnectionError(str(err)) - else: - raise ConnectionError("getaddrinfo returns an empty list") + raise ConnectionError("getaddrinfo returns an empty list") def noidle(self): # noidle's special case if not self._pending or self._pending[0] != 'idle': - raise CommandError( - 'cannot send noidle if send_idle was not called') + raise CommandError('cannot send noidle if send_idle was not called') del self._pending[0] self._write_command("noidle") return self._fetch_list() - def connect(self, host=None, port=None): + def connect(self, host: Optional[str] = None, port: Optional[Union[int, str]] = None): """Connects the MPD server :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details) @@ -656,14 +654,18 @@ class MPDClient: The connect method honors MPD_HOST/MPD_PORT environment variables. - The underlying tcp socket also honors MPD_TIMEOUT environment variable - and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT`. + The underlying socket also honors MPD_TIMEOUT environment variable + and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT` (connect command only). + + If you want to have a timeout for each command once you got connected, + set its value in :py:obj:`MPDClient.socket_timeout` (in second) or at + module level in :py:obj:`musicpd.SOCKET_TIMEOUT`. .. note:: Default host/port If host evaluate to :py:obj:`False` * use ``MPD_HOST`` environment variable if set, extract password if present, - * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket`` + * else looks for an existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket`` * else set host to ``localhost`` If port evaluate to :py:obj:`False` @@ -693,6 +695,18 @@ class MPDClient: self.disconnect() raise + @property + def socket_timeout(self): + """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`). + Use None to disable socket timout.""" + return self._socket_timeout + + @socket_timeout.setter + def socket_timeout(self, timeout): + self._socket_timeout = timeout + if getattr(self._sock, 'settimeout', False): + self._sock.settimeout(self._socket_timeout) + def disconnect(self): """Closes the MPD connection. The client closes the actual socket, it does not use the @@ -708,7 +722,17 @@ class MPDClient: self._sock.close() self._reset() + def __enter__(self): + self.connect() + return self + + def __exit__(self, exception_type, exception_value, exception_traceback): + self.disconnect() + def fileno(self): + """Return the socket’s file descriptor (a small integer). + This is useful with :py:obj:`select.select`. + """ if self._sock is None: raise ConnectionError("Not connected") return self._sock.fileno() @@ -719,8 +743,7 @@ class MPDClient: if self._iterating: raise IteratingError("Cannot begin command list while iterating") if self._pending: - raise PendingCommandError("Cannot begin command list " - "with pending commands") + raise PendingCommandError("Cannot begin command list with pending commands") self._write_command("command_list_ok_begin") self._command_list = [] @@ -733,7 +756,7 @@ class MPDClient: return self._fetch_command_list() -def escape(text): +def escape(text: str) -> str: return text.replace("\\", "\\\\").replace('"', '\\"') # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: