From f0c5d9be755ad75552af73d4dc44c4ae59d382bb Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Sat, 11 Nov 2023 15:52:35 +0100 Subject: [PATCH 01/16] ci: Scheduled pipeline runs test/build --- .gitlab-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8b08de7..4adfebb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,7 +28,7 @@ stages: - changes: - musicpd.py - test.py - - if: $MUSICPD_TEST + - if: $CI_PIPELINE_SOURCE == "schedule" test-py3.11: extends: @@ -91,6 +91,7 @@ build: - .gitlab-ci.yml - musicpd.py - test.py + - if: $CI_PIPELINE_SOURCE == "schedule" tag_release: stage: build @@ -118,6 +119,7 @@ build_doc: - if: $CI_PIPELINE_SOURCE == "push" changes: - doc/source/* + - if: $CI_PIPELINE_SOURCE == "schedule" pages: stage: build -- 2.39.2 From 89914ad1ef6d13963d856836d1b934cb02cfb837 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Sun, 12 Nov 2023 12:02:20 +0100 Subject: [PATCH 02/16] ci: Add python 3.12 test --- .gitlab-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4adfebb..90f133a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,6 +30,12 @@ stages: - test.py - if: $CI_PIPELINE_SOURCE == "schedule" +test-py3.12: + extends: + - .cache_python + - .test + image: "python:3.12" + test-py3.11: extends: - .cache_python -- 2.39.2 From 549a3573ba228d07ef40a2a70b0e23ed2e4c42b2 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Sat, 30 Dec 2023 11:56:53 +0100 Subject: [PATCH 03/16] Fixed "Socket timeout" documentation --- doc/source/use.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/use.rst b/doc/source/use.rst index 119de2d..106ce90 100644 --- a/doc/source/use.rst +++ b/doc/source/use.rst @@ -147,7 +147,7 @@ as a single colon as argument (i.e. sending just ``":"``): Empty start in range (i.e. ":END") are not possible and will raise a CommandError. -Remember the of the tuple is optional, range can still be specified as single string ``"START:END"``. +.. note:: Remember the use of a tuple is **optional**. Range can still be specified as a plain string ``"START:END"``. Iterators ---------- -- 2.39.2 From dc90488713d55a1aa65744dffbfa464da88a0b24 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Sun, 31 Dec 2023 11:37:16 +0100 Subject: [PATCH 04/16] Improved socket timeout setter, add tests --- musicpd.py | 16 +++++++++++++--- test.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/musicpd.py b/musicpd.py index 3e22598..e61188d 100644 --- a/musicpd.py +++ b/musicpd.py @@ -23,7 +23,7 @@ VERSION = '0.9.0b1' #: Seconds before a connection attempt times out #: (overriden by :envvar:`MPD_TIMEOUT` env. var.) CONNECTION_TIMEOUT = 30 -#: Socket timeout in second (Default is None for no timeout) +#: Socket timeout in second > 0 (Default is :py:obj:`None` for no timeout) SOCKET_TIMEOUT = None log = logging.getLogger(__name__) @@ -727,15 +727,25 @@ class MPDClient: @property def socket_timeout(self): """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`). - Use None to disable socket timout.""" + Use :py:obj:`None` to disable socket timout. + + :setter: Set the socket timeout + :type: int or None (integer > 0) + """ return self._socket_timeout @socket_timeout.setter def socket_timeout(self, timeout): - self._socket_timeout = timeout + if timeout is not None: + if int(timeout) <= 0: + raise ValueError('socket_timeout expects a non zero positive integer') + self._socket_timeout = int(timeout) + else: + 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 diff --git a/test.py b/test.py index 0677905..033fe06 100755 --- a/test.py +++ b/test.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # coding: utf-8 -# SPDX-FileCopyrightText: 2012-2021 kaliko +# SPDX-FileCopyrightText: 2012-2023 kaliko # SPDX-License-Identifier: LGPL-3.0-or-later # pylint: disable=missing-docstring """ @@ -588,6 +588,35 @@ class testConnection(unittest.TestCase): cli.connect() sock.connect.assert_called_with('/run/mpd/socket') + def test_sockettimeout(self): + with mock.patch('musicpd.socket') as socket_mock: + sock = mock.MagicMock(name='socket') + socket_mock.socket.return_value = sock + cli = musicpd.MPDClient() + # Default is no socket timeout + cli.connect() + sock.settimeout.assert_called_with(None) + cli.disconnect() + # set a socket timeout before connection + cli.socket_timeout = 10 + cli.connect() + sock.settimeout.assert_called_with(10) + # Set socket timeout while already connected + cli.socket_timeout = 42 + sock.settimeout.assert_called_with(42) + # set a socket timeout using str + cli.socket_timeout = '10' + sock.settimeout.assert_called_with(10) + # Set socket timeout to None + cli.socket_timeout = None + sock.settimeout.assert_called_with(None) + # Set socket timeout Raises Exception + with self.assertRaises(ValueError): + cli.socket_timeout = 'foo' + with self.assertRaises(ValueError, msg='socket_timeout expects a non zero positive integer'): + cli.socket_timeout = '0' + with self.assertRaises(ValueError, msg='socket_timeout expects a non zero positive integer'): + cli.socket_timeout = '-1' class testException(unittest.TestCase): -- 2.39.2 From 2025516f2cb5f3ac8f6e5b3c1c85174649e9dbd1 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Sun, 4 Feb 2024 17:38:49 +0100 Subject: [PATCH 05/16] f-string conversion --- musicpd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/musicpd.py b/musicpd.py index e61188d..8b5d789 100644 --- a/musicpd.py +++ b/musicpd.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# SPDX-FileCopyrightText: 2012-2023 kaliko +# SPDX-FileCopyrightText: 2012-2024 kaliko # SPDX-FileCopyrightText: 2021 Wonko der Verständige # SPDX-FileCopyrightText: 2019 Naglis Jonaitis # SPDX-FileCopyrightText: 2019 Bart Van Loon @@ -436,9 +436,9 @@ class MPDClient: parts = [command] for arg in args: if isinstance(arg, tuple): - parts.append('{0!s}'.format(Range(arg))) + parts.append(f'{Range(arg)!s}') else: - parts.append('"%s"' % escape(str(arg))) + parts.append(f'"{escape(str(arg))}"') if '\n' in ' '.join(parts): raise CommandError('new line found in the command!') self._write_line(" ".join(parts)) -- 2.39.2 From d6d480d5611d5a412555d6887dbac983880123c3 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Tue, 6 Feb 2024 16:58:10 +0100 Subject: [PATCH 06/16] Gather more OSError exception in musicpd.ConnectionError --- CHANGES.txt | 2 ++ musicpd.py | 27 ++++++++++++------- test.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index d32426a..52e46d1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -10,6 +10,8 @@ Changes in 0.9.0 * Improved Range object to deal with window parameter * Add logging * Switch to pyproject.toml (setuptools build system) + * The connect sequence raises ConnectionError only on error, + previously getaddrinfo or unix socket connection error raised an OSError Changes in 0.8.0 ---------------- diff --git a/musicpd.py b/musicpd.py index 8b5d789..d8dc01b 100644 --- a/musicpd.py +++ b/musicpd.py @@ -19,7 +19,7 @@ ERROR_PREFIX = "ACK " SUCCESS = "OK" NEXT = "list_OK" #: Module version -VERSION = '0.9.0b1' +VERSION = '0.9.0b2' #: Seconds before a connection attempt times out #: (overriden by :envvar:`MPD_TIMEOUT` env. var.) CONNECTION_TIMEOUT = 30 @@ -642,10 +642,13 @@ class MPDClient: # 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) + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.mpd_timeout) + sock.connect(path) + sock.settimeout(self.socket_timeout) + except socket.error as socket_err: + raise ConnectionError(socket_err.strerror) from socket_err return sock def _connect_tcp(self, host, port): @@ -654,23 +657,29 @@ class MPDClient: except AttributeError: flags = 0 err = None - for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, - socket.SOCK_STREAM, socket.IPPROTO_TCP, - flags): + try: + gai = socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM, socket.IPPROTO_TCP, + flags) + except socket.error as gaierr: + raise ConnectionError(gaierr.strerror) from gaierr + for res in gai: af, socktype, proto, _, sa = res sock = None try: + log.debug('opening socket %s', sa) sock = socket.socket(af, socktype, proto) sock.settimeout(self.mpd_timeout) sock.connect(sa) sock.settimeout(self.socket_timeout) return sock except socket.error as socket_err: + log.debug('opening socket %s failed: %s}', sa, socket_err) err = socket_err if sock is not None: sock.close() if err is not None: - raise ConnectionError(str(err)) + raise ConnectionError(err.strerror) raise ConnectionError("getaddrinfo returns an empty list") def noidle(self): diff --git a/test.py b/test.py index 033fe06..a123b30 100755 --- a/test.py +++ b/test.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # coding: utf-8 -# SPDX-FileCopyrightText: 2012-2023 kaliko +# SPDX-FileCopyrightText: 2012-2024 kaliko # SPDX-License-Identifier: LGPL-3.0-or-later # pylint: disable=missing-docstring """ @@ -14,11 +14,12 @@ import itertools import os import types import unittest -import unittest.mock as mock +import unittest.mock import warnings import musicpd +mock = unittest.mock # show deprecation warnings warnings.simplefilter('default') @@ -27,7 +28,7 @@ warnings.simplefilter('default') TEST_MPD_HOST, TEST_MPD_PORT = ('example.com', 10000) -class testEnvVar(unittest.TestCase): +class TestEnvVar(unittest.TestCase): def test_envvar(self): # mock "os.path.exists" here to ensure there are no socket in @@ -559,7 +560,7 @@ class TestMPDClient(unittest.TestCase): with self.assertRaises(AttributeError): self.client.foo_bar() -class testConnection(unittest.TestCase): +class TestConnection(unittest.TestCase): def test_exposing_fileno(self): with mock.patch('musicpd.socket') as socket_mock: @@ -613,14 +614,72 @@ class testConnection(unittest.TestCase): # Set socket timeout Raises Exception with self.assertRaises(ValueError): cli.socket_timeout = 'foo' - with self.assertRaises(ValueError, msg='socket_timeout expects a non zero positive integer'): + with self.assertRaises(ValueError, + msg='socket_timeout expects a non zero positive integer'): cli.socket_timeout = '0' - with self.assertRaises(ValueError, msg='socket_timeout expects a non zero positive integer'): + with self.assertRaises(ValueError, + msg='socket_timeout expects a non zero positive integer'): cli.socket_timeout = '-1' -class testException(unittest.TestCase): - def test_CommandError_on_newline(self): +class TestConnectionError(unittest.TestCase): + + @mock.patch('socket.socket') + def test_connect_unix(self, socket_mock): + """Unix socket socket.error should raise a musicpd.ConnectionError""" + mocked_socket = socket_mock.return_value + mocked_socket.connect.side_effect = musicpd.socket.error(42, 'err 42') + os.environ['MPD_HOST'] = '/run/mpd/socket' + cli = musicpd.MPDClient() + with self.assertRaises(musicpd.ConnectionError) as cme: + cli.connect() + self.assertEqual('err 42', str(cme.exception)) + + def test_non_available_unix_socket(self): + delattr(musicpd.socket, 'AF_UNIX') + os.environ['MPD_HOST'] = '/run/mpd/socket' + cli = musicpd.MPDClient() + with self.assertRaises(musicpd.ConnectionError) as cme: + cli.connect() + self.assertEqual('Unix domain sockets not supported on this platform', + str(cme.exception)) + + @mock.patch('socket.getaddrinfo') + def test_connect_tcp_getaddrinfo(self, gai_mock): + """TCP socket.gaierror should raise a musicpd.ConnectionError""" + gai_mock.side_effect = musicpd.socket.error(42, 'gaierr 42') + cli = musicpd.MPDClient() + with self.assertRaises(musicpd.ConnectionError) as cme: + cli.connect(host=TEST_MPD_HOST) + self.assertEqual('gaierr 42', str(cme.exception)) + + @mock.patch('socket.getaddrinfo') + @mock.patch('socket.socket') + def test_connect_tcp_connect(self, socket_mock, gai_mock): + """A socket.error should raise a musicpd.ConnectionError + Mocking getaddrinfo to prevent network access (DNS) + """ + gai_mock.return_value = [range(5)] + mocked_socket = socket_mock.return_value + mocked_socket.connect.side_effect = musicpd.socket.error(42, 'tcp conn err 42') + cli = musicpd.MPDClient() + with self.assertRaises(musicpd.ConnectionError) as cme: + cli.connect(host=TEST_MPD_HOST) + self.assertEqual('tcp conn err 42', str(cme.exception)) + + @mock.patch('socket.getaddrinfo') + def test_connect_tcp_connect_empty_gai(self, gai_mock): + """An empty getaddrinfo should raise a musicpd.ConnectionError""" + gai_mock.return_value = [] + cli = musicpd.MPDClient() + with self.assertRaises(musicpd.ConnectionError) as cme: + cli.connect(host=TEST_MPD_HOST) + self.assertEqual('getaddrinfo returns an empty list', str(cme.exception)) + + +class TestCommandErrorException(unittest.TestCase): + + def test_error_on_newline(self): os.environ['MPD_HOST'] = '/run/mpd/socket' with mock.patch('musicpd.socket') as socket_mock: sock = mock.MagicMock(name='socket') -- 2.39.2 From 43a63d085fab34f337ceec232e29e5760f4fcc25 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Tue, 6 Feb 2024 18:30:49 +0100 Subject: [PATCH 07/16] Fixed ConnectionError argument Timeout does not expose errno, strerror attributes --- musicpd.py | 8 ++++---- test.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/musicpd.py b/musicpd.py index d8dc01b..e6b1c05 100644 --- a/musicpd.py +++ b/musicpd.py @@ -648,7 +648,7 @@ class MPDClient: sock.connect(path) sock.settimeout(self.socket_timeout) except socket.error as socket_err: - raise ConnectionError(socket_err.strerror) from socket_err + raise ConnectionError(socket_err) from socket_err return sock def _connect_tcp(self, host, port): @@ -662,7 +662,7 @@ class MPDClient: socket.SOCK_STREAM, socket.IPPROTO_TCP, flags) except socket.error as gaierr: - raise ConnectionError(gaierr.strerror) from gaierr + raise ConnectionError(gaierr) from gaierr for res in gai: af, socktype, proto, _, sa = res sock = None @@ -674,12 +674,12 @@ class MPDClient: sock.settimeout(self.socket_timeout) return sock except socket.error as socket_err: - log.debug('opening socket %s failed: %s}', sa, socket_err) + log.debug('opening socket %s failed: %s', sa, socket_err) err = socket_err if sock is not None: sock.close() if err is not None: - raise ConnectionError(err.strerror) + raise ConnectionError(err) raise ConnectionError("getaddrinfo returns an empty list") def noidle(self): diff --git a/test.py b/test.py index a123b30..299b54a 100755 --- a/test.py +++ b/test.py @@ -633,7 +633,7 @@ class TestConnectionError(unittest.TestCase): cli = musicpd.MPDClient() with self.assertRaises(musicpd.ConnectionError) as cme: cli.connect() - self.assertEqual('err 42', str(cme.exception)) + self.assertEqual('[Errno 42] err 42', str(cme.exception)) def test_non_available_unix_socket(self): delattr(musicpd.socket, 'AF_UNIX') @@ -651,7 +651,7 @@ class TestConnectionError(unittest.TestCase): cli = musicpd.MPDClient() with self.assertRaises(musicpd.ConnectionError) as cme: cli.connect(host=TEST_MPD_HOST) - self.assertEqual('gaierr 42', str(cme.exception)) + self.assertEqual('[Errno 42] gaierr 42', str(cme.exception)) @mock.patch('socket.getaddrinfo') @mock.patch('socket.socket') @@ -665,7 +665,7 @@ class TestConnectionError(unittest.TestCase): cli = musicpd.MPDClient() with self.assertRaises(musicpd.ConnectionError) as cme: cli.connect(host=TEST_MPD_HOST) - self.assertEqual('tcp conn err 42', str(cme.exception)) + self.assertEqual('[Errno 42] tcp conn err 42', str(cme.exception)) @mock.patch('socket.getaddrinfo') def test_connect_tcp_connect_empty_gai(self, gai_mock): -- 2.39.2 From c7b2fbbb9689a180f220322b21e2b0bef798eb68 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Tue, 6 Feb 2024 18:43:45 +0100 Subject: [PATCH 08/16] Add examples --- doc/source/examples.rst | 44 ++++++++++++++ doc/source/examples/client.py | 94 +++++++++++++++++++++++++++++ doc/source/examples/connect.py | 30 +++++++++ doc/source/examples/connect_host.py | 17 ++++++ doc/source/examples/findadd.py | 8 +++ doc/source/examples/playback.py | 7 +++ doc/source/index.rst | 1 + doc/source/use.rst | 12 +++- 8 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 doc/source/examples.rst create mode 100644 doc/source/examples/client.py create mode 100644 doc/source/examples/connect.py create mode 100644 doc/source/examples/connect_host.py create mode 100644 doc/source/examples/findadd.py create mode 100644 doc/source/examples/playback.py diff --git a/doc/source/examples.rst b/doc/source/examples.rst new file mode 100644 index 0000000..291344c --- /dev/null +++ b/doc/source/examples.rst @@ -0,0 +1,44 @@ +.. SPDX-FileCopyrightText: 2018-2023 kaliko +.. SPDX-License-Identifier: LGPL-3.0-or-later + +.. _examples: + +Examples +======== + +Plain examples +-------------- + +Connect, if playing, get currently playing track, the next one: + +.. literalinclude:: examples/connect.py + :language: python + :linenos: + +Connect a specific password protected host: + +.. literalinclude:: examples/connect_host.py + :language: python + :linenos: + +Start playing current queue and set the volume: + +.. literalinclude:: examples/playback.py + :language: python + :linenos: + +Clear the queue, search artist, queue what's found and play: + +.. literalinclude:: examples/findadd.py + :language: python + :linenos: + +Object Oriented example +----------------------- + +A plain client monitoring changes on MPD. + +.. literalinclude:: examples/client.py + :language: python + :linenos: + diff --git a/doc/source/examples/client.py b/doc/source/examples/client.py new file mode 100644 index 0000000..41ef1bd --- /dev/null +++ b/doc/source/examples/client.py @@ -0,0 +1,94 @@ +"""Plain client class +""" +import logging +import select +import sys + +import musicpd + + +class MyClient(musicpd.MPDClient): + """Plain client inheriting from MPDClient""" + + def __init__(self): + # Set logging to debug level + logging.basicConfig(level=logging.DEBUG, + format='%(levelname)-8s %(message)s') + self.log = logging.getLogger(__name__) + super().__init__() + # Set host/port/password after init to overrides defaults + # self.host = 'example.org' + # self.port = 4242 + # self.pwd = 'secret' + + def connect(self): + """Overriding explicitly MPDClient.connect()""" + try: + super().connect(host=self.host, port=self.port) + if hasattr(self, 'pwd') and self.pwd: + self.password(self.pwd) + except musicpd.ConnectionError as err: + # Catch socket error + self.log.error('Failed to connect: %s', err) + sys.exit(42) + + def _wait_for_changes(self, callback): + select_timeout = 10 # second + while True: + self.send_idle() # use send_ API to avoid blocking on read + _read, _, _ = select.select([self], [], [], select_timeout) + if _read: # tries to read response + ret = self.fetch_idle() + # do something + callback(ret) + else: # cancels idle + self.noidle() + + def callback(self, *args): + """Method launch on MPD event, cf. monitor method""" + self.log.info('%s', args) + + def monitor(self): + """Continuously monitor MPD activity. + Launch callback method on event. + """ + try: + self._wait_for_changes(self.callback) + except (OSError, musicpd.MPDError) as err: + self.log.error('%s: Something went wrong: %s', + type(err).__name__, err) + +if __name__ == '__main__': + cli = MyClient() + # You can overrides host here or in init + #cli.host = 'example.org' + # Connect MPD server + try: + cli.connect() + except musicpd.ConnectionError as err: + cli.log.error(err) + + # Monitor MPD changes, blocking/timeout idle approach + try: + cli.socket_timeout = 20 # seconds + ret = cli.idle() + cli.log.info('Leaving idle, got: %s', ret) + except TimeoutError as err: + cli.log.info('Nothing occured the last %ss', cli.socket_timeout) + + # Reset connection + try: + cli.socket_timeout = None + cli.disconnect() + cli.connect() + except musicpd.ConnectionError as err: + cli.log.error(err) + + # Monitor MPD changes, non blocking idle approach + try: + cli.monitor() + except KeyboardInterrupt as err: + cli.log.info(type(err).__name__) + cli.send_noidle() + cli.disconnect() + diff --git a/doc/source/examples/connect.py b/doc/source/examples/connect.py new file mode 100644 index 0000000..0c74448 --- /dev/null +++ b/doc/source/examples/connect.py @@ -0,0 +1,30 @@ +import musicpd +import logging + +import musicpd + +# Set logging to debug level +# it should log messages showing where defaults come from +logging.basicConfig(level=logging.DEBUG, format='%(levelname)-8s %(message)s') +log = logging.getLogger() + +client = musicpd.MPDClient() +# use MPD_HOST/MPD_PORT env var if set else +# test ${XDG_RUNTIME_DIR}/mpd/socket for existence +# fallback to localhost:6600 +# connect support host/port argument as well +client.connect() + +status = client.status() +if status.get('state') == 'play': + current_song_id = status.get('songid') + current_song = client.playlistid(current_song_id)[0] + log.info(f'Playing : {current_song.get("file")}') + next_song_id = status.get('nextsongid', None) + if next_song_id: + next_song = client.playlistid(next_song_id)[0] + log.info(f'Next song : {next_song.get("file")}') +else: + log.info('Not playing') + +client.disconnect() diff --git a/doc/source/examples/connect_host.py b/doc/source/examples/connect_host.py new file mode 100644 index 0000000..a7bdccc --- /dev/null +++ b/doc/source/examples/connect_host.py @@ -0,0 +1,17 @@ +import sys +import logging + +import musicpd + +# Set logging to debug level +logging.basicConfig(level=logging.DEBUG, format='%(levelname)-8s %(message)s') + +client = musicpd.MPDClient() +try: + client.connect(host='example.lan') + client.password('secret') + client.status() +except musicpd.MPDError as err: + print(f'An error occured: {err}') +finally: + client.disconnect() diff --git a/doc/source/examples/findadd.py b/doc/source/examples/findadd.py new file mode 100644 index 0000000..922cae1 --- /dev/null +++ b/doc/source/examples/findadd.py @@ -0,0 +1,8 @@ +import musicpd + +# Using a context manager +# (use env var if you need to override default host) +with musicpd.MPDClient() as client: + client.clear() + client.findadd("(artist == 'Monkey3')") + client.play() diff --git a/doc/source/examples/playback.py b/doc/source/examples/playback.py new file mode 100644 index 0000000..ce7eb78 --- /dev/null +++ b/doc/source/examples/playback.py @@ -0,0 +1,7 @@ +import musicpd + +# Using a context manager +# (use env var if you need to override default host) +with musicpd.MPDClient() as client: + client.play() + client.setvol('80') diff --git a/doc/source/index.rst b/doc/source/index.rst index 9b4b43d..d16644b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -69,6 +69,7 @@ Contents use.rst doc.rst commands.rst + examples.rst contribute.rst diff --git a/doc/source/use.rst b/doc/source/use.rst index 106ce90..7029684 100644 --- a/doc/source/use.rst +++ b/doc/source/use.rst @@ -36,10 +36,16 @@ strings. In the example above, an integer can be used as argument for the written to the socket. To avoid confusion use regular string instead of relying on object string representation. -:py:class:`musicpd.MPDClient` methods returns different kinds of objects depending on the command. Could be :py:obj:`None`, a single object as a :py:obj:`str` or a :py:obj:`dict`, a list of :py:obj:`dict`. +:py:class:`musicpd.MPDClient` methods returns different kinds of objects +depending on the command. Could be :py:obj:`None`, a single object as a +:py:obj:`str` or a :py:obj:`dict`, a list of :py:obj:`dict`. -For more about the protocol and MPD commands see the `MPD protocol -documentation`_. +Then :py:class:`musicpd.MPDClient` **methods signatures** are not hard coded +within this module since the protocol is handled on the server side. Please +refer to the protocol and MPD commands in `MPD protocol documentation`_ to +learn how to call commands and what kind of arguments they expect. + +Some examples are provided for the most common cases, see :ref:`examples`. For a list of currently supported commands in this python module see :ref:`commands`. -- 2.39.2 From 31cd41780977d5246559a7dead3eea77520f9deb Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Wed, 7 Feb 2024 18:26:19 +0100 Subject: [PATCH 09/16] Document Exceptions --- doc/source/use.rst | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/doc/source/use.rst b/doc/source/use.rst index 7029684..e8d6d53 100644 --- a/doc/source/use.rst +++ b/doc/source/use.rst @@ -218,7 +218,7 @@ Fetching album covers is possible with albumart, here is an example: >>> print('something went wrong', file=sys.stderr) >>> cli.disconnect() -A `CommandError` is raised if the album does not expose a cover. +A :py:obj:`musicpd.CommandError` is raised if the album does not expose a cover. You can also use `readpicture` command to fetch embedded picture: @@ -314,6 +314,15 @@ could also be that MPD took too much time to answer, but MPD taking more than a couple of seconds for these commands should never occur). +Exceptions +---------- + +The :py:obj:`musicpd.MPDClient.connect` method raises +:py:obj:`musicpd.ConnectionError` only but then, calling other MPD commands, +the module can raise :py:obj:`musicpd.MPDError` or an :py:obj:`OSError` depending on the error and where it occurs. + +Using musicpd module both :py:obj:`musicpd.MPDError` and :py:obj:`OSError` exceptions families are expected. + .. _MPD protocol documentation: http://www.musicpd.org/doc/protocol/ .. _snake case: https://en.wikipedia.org/wiki/Snake_case .. vim: spell spelllang=en -- 2.39.2 From e260e75498f3595d8b78b43ccf62c0d359ed0101 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Sun, 11 Feb 2024 14:07:16 +0100 Subject: [PATCH 10/16] fixed sphinx warnings --- doc/source/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index e0a2bd1..71de763 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -76,7 +76,7 @@ release = VERSION # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +#language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -438,7 +438,7 @@ epub_exclude_files = ['search.html'] # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} # autodoc config autodoc_member_order = 'bysource' -- 2.39.2 From 2399137b973746fdc4286856e1d78c22c7fcaff1 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Sun, 25 Feb 2024 14:23:20 +0100 Subject: [PATCH 11/16] Fixed confusing debug message Removed "@" in front of hostname to avoid confusion with abstract socket --- musicpd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/musicpd.py b/musicpd.py index e6b1c05..2fa1233 100644 --- a/musicpd.py +++ b/musicpd.py @@ -349,7 +349,7 @@ class MPDClient: else: # MPD_HOST is a plain host self.host = _host - log.debug('host detected in MPD_HOST: @%s', self.host) + log.debug('host detected in MPD_HOST: %s', self.host) else: # Is socket there xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run') -- 2.39.2 From 9aa136ff3dc89f1f9a396f8404eb4c8064fa891c Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Wed, 10 Apr 2024 17:23:47 +0200 Subject: [PATCH 12/16] Improved exceptions doc, fixed sphinx warnings --- .gitlab-ci.yml | 2 +- CHANGES.txt | 1 + doc/source/examples.rst | 13 +++++++ doc/source/examples/connect.py | 1 - doc/source/examples/exceptions.py | 59 +++++++++++++++++++++++++++++++ doc/source/use.rst | 18 ++++++---- musicpd.py | 8 ++--- 7 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 doc/source/examples/exceptions.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 90f133a..d55875f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -120,7 +120,7 @@ build_doc: stage: build script: - pip install sphinx sphinx_rtd_theme - - sphinx-build doc/source -b html ./html -D html_theme=sphinx_rtd_theme + - sphinx-build doc/source -b html ./html -D html_theme=sphinx_rtd_theme -E -W -n --keep-going rules: - if: $CI_PIPELINE_SOURCE == "push" changes: diff --git a/CHANGES.txt b/CHANGES.txt index 52e46d1..fe285d3 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -12,6 +12,7 @@ Changes in 0.9.0 * Switch to pyproject.toml (setuptools build system) * The connect sequence raises ConnectionError only on error, previously getaddrinfo or unix socket connection error raised an OSError + * Improved documentation, add examples Changes in 0.8.0 ---------------- diff --git a/doc/source/examples.rst b/doc/source/examples.rst index 291344c..cf4c916 100644 --- a/doc/source/examples.rst +++ b/doc/source/examples.rst @@ -42,3 +42,16 @@ A plain client monitoring changes on MPD. :language: python :linenos: +.. _exceptions_example: + +Dealing with Exceptions +----------------------- + +Musicpd module will raise it's own :py:obj:`MPDError` +exceptions **and** python :py:obj:`OSError`. Then you can wrap +:py:obj:`OSError` in :py:obj:`MPDError` exceptions to have to deal +with a single type of exceptions in your code: + +.. literalinclude:: examples/exceptions.py + :language: python + :linenos: diff --git a/doc/source/examples/connect.py b/doc/source/examples/connect.py index 0c74448..3138464 100644 --- a/doc/source/examples/connect.py +++ b/doc/source/examples/connect.py @@ -1,4 +1,3 @@ -import musicpd import logging import musicpd diff --git a/doc/source/examples/exceptions.py b/doc/source/examples/exceptions.py new file mode 100644 index 0000000..1d10c32 --- /dev/null +++ b/doc/source/examples/exceptions.py @@ -0,0 +1,59 @@ +"""client class dealing with all Exceptions +""" +import logging + +import musicpd + + +# Wrap Exception decorator +def wrapext(func): + """Decorator to wrap errors in musicpd.MPDError""" + errors=(OSError, TimeoutError) + into = musicpd.MPDError + def w_func(*args, **kwargs): + try: + return func(*args, **kwargs) + except errors as err: + strerr = str(err) + if hasattr(err, 'strerror'): + if err.strerror: + strerr = err.strerror + raise into(strerr) from err + return w_func + + +class MyClient(musicpd.MPDClient): + """Plain client inheriting from MPDClient""" + + def __init__(self): + # Set logging to debug level + logging.basicConfig(level=logging.DEBUG, + format='%(levelname)-8s %(module)-10s %(message)s') + self.log = logging.getLogger(__name__) + super().__init__() + + @wrapext + def __getattr__(self, cmd): + """Wrapper around MPDClient calls for abstract overriding""" + self.log.debug('cmd: %s', cmd) + return super().__getattr__(cmd) + + +if __name__ == '__main__': + cli = MyClient() + # You can overrides host here or in init + #cli.host = 'example.org' + # Connect MPD server + try: + cli.connect() + cli.currentsong() + cli.stats() + except musicpd.MPDError as error: + cli.log.fatal(error) + finally: + cli.log.info('Disconnecting') + try: + # Tries to close the socket anyway + cli.disconnect() + except OSError: + pass diff --git a/doc/source/use.rst b/doc/source/use.rst index e8d6d53..88728ef 100644 --- a/doc/source/use.rst +++ b/doc/source/use.rst @@ -84,15 +84,15 @@ Default settings Default host: * use :envvar:`MPD_HOST` environment variable if set, extract password if present, - * else looks for an existing file in :envvar:`${XDG_RUNTIME_DIR:-/run/}/mpd/socket` + * else use :envvar:`XDG_RUNTIME_DIR` to looks for an existing file in ``${XDG_RUNTIME_DIR}/mpd/socket``, :envvar:`XDG_RUNTIME_DIR` defaults to ``/run`` if not set. * else set host to ``localhost`` Default port: - * use :envvar:`MPD_PORT` environment variable is set + * use :envvar:`MPD_PORT` environment variable if set * else use ``6600`` Default timeout: - * use :envvar:`MPD_TIMEOUT` is set + * use :envvar:`MPD_TIMEOUT` if set * else use :py:obj:`musicpd.CONNECTION_TIMEOUT` Context manager @@ -313,15 +313,19 @@ triggering a socket timeout unless the connection is actually lost (actually it could also be that MPD took too much time to answer, but MPD taking more than a couple of seconds for these commands should never occur). +.. _exceptions: Exceptions ---------- -The :py:obj:`musicpd.MPDClient.connect` method raises -:py:obj:`musicpd.ConnectionError` only but then, calling other MPD commands, -the module can raise :py:obj:`musicpd.MPDError` or an :py:obj:`OSError` depending on the error and where it occurs. +The :py:obj:`connect` method raises +:py:obj:`ConnectionError` only (an :py:obj:`MPDError` exception) but then, calling other MPD commands, the module can raise +:py:obj:`MPDError` or an :py:obj:`OSError` depending on the error and +where it occurs. -Using musicpd module both :py:obj:`musicpd.MPDError` and :py:obj:`OSError` exceptions families are expected. +Then using musicpd module both :py:obj:`musicpd.MPDError` and :py:obj:`OSError` +exceptions families are expected, see :ref:`examples` for a +way to deal with this. .. _MPD protocol documentation: http://www.musicpd.org/doc/protocol/ .. _snake case: https://en.wikipedia.org/wiki/Snake_case diff --git a/musicpd.py b/musicpd.py index 2fa1233..10bcdc4 100644 --- a/musicpd.py +++ b/musicpd.py @@ -164,12 +164,12 @@ class MPDClient: :py:attr:`musicpd.MPDClient.port` keep track of current connection host and port, :py:attr:`musicpd.MPDClient.pwd` is set once with password extracted from environment variable. - Calling :py:meth:`password` methode with a new password + Calling MPS's password method with a new password won't update :py:attr:`musicpd.MPDClient.pwd` value. Moreover, :py:attr:`musicpd.MPDClient.pwd` is only an helper attribute exposing password extracted from :envvar:`MPD_HOST` environment variable, it - will not be used as default value for the :py:meth:`password` method + will not be used as default value for the MPD's password command. """ def __init__(self): @@ -738,8 +738,8 @@ class MPDClient: """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`). Use :py:obj:`None` to disable socket timout. - :setter: Set the socket timeout - :type: int or None (integer > 0) + :setter: Set the socket timeout (integer > 0) + :type: int or None """ return self._socket_timeout -- 2.39.2 From cd10c63272151a8cab942cf21ad63668cf17397d Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Wed, 10 Apr 2024 18:14:15 +0200 Subject: [PATCH 13/16] Add module name in logger in example --- doc/source/examples/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/examples/client.py b/doc/source/examples/client.py index 41ef1bd..3598673 100644 --- a/doc/source/examples/client.py +++ b/doc/source/examples/client.py @@ -13,7 +13,7 @@ class MyClient(musicpd.MPDClient): def __init__(self): # Set logging to debug level logging.basicConfig(level=logging.DEBUG, - format='%(levelname)-8s %(message)s') + format='%(levelname)-8s %(module)-8s %(message)s') self.log = logging.getLogger(__name__) super().__init__() # Set host/port/password after init to overrides defaults -- 2.39.2 From 1755eee900ee766c8e3f5224419605b4b0f27bc5 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Thu, 11 Apr 2024 19:12:16 +0200 Subject: [PATCH 14/16] Improved/fixed pyproject --- MANIFEST.in | 3 ++- pyproject.toml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index fd43aca..8f82cfa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include CHANGES.txt -recursive-include doc/source * +graft doc/source +global-exclude *~ *.py[cod] *.so diff --git a/pyproject.toml b/pyproject.toml index 971c94b..a3f46bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,5 +30,8 @@ sphinx = ["Sphinx>=5.3.0"] [build-system] requires = ["setuptools>=61.0.0"] +[tool.setuptools] +py-modules = ["musicpd"] + [tool.setuptools.dynamic] version = {attr = "musicpd.VERSION"} -- 2.39.2 From 06eafbe03574c0797f014def1fe4f6afb1df3f87 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Fri, 12 Apr 2024 19:22:07 +0200 Subject: [PATCH 15/16] ci: Intercept pyproject changes --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d55875f..6576783 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -97,6 +97,8 @@ build: - .gitlab-ci.yml - musicpd.py - test.py + - MANIFEST.in + - pyproject.toml - if: $CI_PIPELINE_SOURCE == "schedule" tag_release: -- 2.39.2 From 1d10477733e0c6dfe0137b71203f3f094034bcd0 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Wed, 10 Apr 2024 18:14:21 +0200 Subject: [PATCH 16/16] Releasing 0.9.0 --- musicpd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/musicpd.py b/musicpd.py index 10bcdc4..e24b51d 100644 --- a/musicpd.py +++ b/musicpd.py @@ -19,7 +19,7 @@ ERROR_PREFIX = "ACK " SUCCESS = "OK" NEXT = "list_OK" #: Module version -VERSION = '0.9.0b2' +VERSION = '0.9.0' #: Seconds before a connection attempt times out #: (overriden by :envvar:`MPD_TIMEOUT` env. var.) CONNECTION_TIMEOUT = 30 -- 2.39.2