From: Kaliko Jack Date: Wed, 10 Apr 2024 16:14:21 +0000 (+0200) Subject: Releasing 0.9.0 X-Git-Tag: v0.9.0^0 X-Git-Url: http://git.kaliko.me/?p=python-musicpd.git;a=commitdiff_plain;h=refs%2Fheads%2Fmaster;hp=fbd246c4d5f39513b24ac88b5fb19c0faa581f2f Releasing 0.9.0 --- diff --git a/.gitignore b/.gitignore index 3ca37ef..705efe3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2012-2022 kaliko -# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-License-Identifier: LGPL-3.0-or-later # python __pycache__ *.pyc. diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index de4dc95..6576783 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,4 @@ +--- image: python:latest before_script: @@ -7,69 +8,134 @@ stages: - test - build +.cache_python: + variables: + FF_USE_FASTZIP: 1 # enable faster caching/artifacting + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: # cache the venv and pip cache (you may opt to use just 1 of these) + - $PIP_CACHE_DIR + .test: stage: test script: - - pip install pytest-cov - - py.test -q --cov=musicpd test.py - only: - - pushes + - python -m venv venv + - source venv/bin/activate + - pip install pytest-cov + - py.test -q --cov=musicpd test.py + rules: + - changes: + - musicpd.py + - test.py + - if: $CI_PIPELINE_SOURCE == "schedule" + +test-py3.12: + extends: + - .cache_python + - .test + image: "python:3.12" + +test-py3.11: + extends: + - .cache_python + - .test + image: "python:3.11" test-py3.10: - extends: ".test" + extends: + - .cache_python + - .test image: "python:3.10" coverage: '/musicpd.py\s+\d+\s+\d+\s+(\d+)%/' test-py3.9: - extends: ".test" + extends: + - .cache_python + - .test image: "python:3.9" test-py3.8: - extends: ".test" + extends: + - .cache_python + - .test image: "python:3.8" test-py3.7: - extends: ".test" + extends: + - .cache_python + - .test image: "python:3.7" +test-py3.6: + extends: + - .cache_python + - .test + image: "python:3.6" + build: stage: build + extends: + - .cache_python script: - # packaging test - - python setup.py bdist_wheel sdist - - pip install dist/*.whl - - pip install twine - - twine check dist/* + - python -m venv venv + - source venv/bin/activate + - pip install build + # packaging test + - python3 -m build -s -w + - pip install dist/*.whl + - pip install twine + - twine check dist/* artifacts: expire_in: 1 week paths: - - dist/*.whl - - dist/*.tar.gz - - dist/*.zip - only: - - pushes + - dist/*.* + rules: + - if: $CI_PIPELINE_SOURCE == "push" + changes: + - .gitlab-ci.yml + - musicpd.py + - test.py + - MANIFEST.in + - pyproject.toml + - if: $CI_PIPELINE_SOURCE == "schedule" tag_release: stage: build + extends: + - .cache_python script: - - python setup.py bdist_wheel sdist + - python -m venv venv + - source venv/bin/activate + - pip install build + - python3 -m build -s -w artifacts: paths: - - dist/*.whl - - dist/*.tar.gz - - dist/*.zip + - dist/*.* name: "$CI_PROJECT_NAME-$CI_COMMIT_TAG" - only: - - tags + rules: + - if: $CI_COMMIT_TAG + +# Documentation +build_doc: + stage: build + script: + - pip install sphinx 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: + - doc/source/* + - if: $CI_PIPELINE_SOURCE == "schedule" pages: stage: build script: - - pip install sphinx sphinx_rtd_theme - - sphinx-build -d ./build/doctrees doc/source -b html ./public -D html_theme=sphinx_rtd_theme + - pip install sphinx sphinx_rtd_theme + - sphinx-build -d ./build/doctrees doc/source -b html ./public -D html_theme=sphinx_rtd_theme artifacts: paths: - - public - only: - - master + - public + rules: + - if: $CI_COMMIT_BRANCH == "master" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b00b506..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: python - -branches: - only: - - dev - -python: - - "3.7" - - "3.8" - - "3.9" - -script: python3 setup.py test diff --git a/CHANGES.txt b/CHANGES.txt index 4b0648d..fe285d3 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,6 +1,19 @@ python-musicpd Changes List =========================== +Changes in 0.9.0 +---------------- + + * Use right SPDX identifier for license headers + * mpd_version attribute init to empty string instead of None + * Fixed send_noidle (introduced with e8daa719) + * 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 + * Improved documentation, add examples + Changes in 0.8.0 ---------------- 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/README.rst b/README.rst index 88c7430..825dd16 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -Python MusicPlayerDaemon client module -*************************************** +Music Player Daemon client module +********************************* An MPD (Music Player Daemon) client library written in pure Python. diff --git a/doc/source/commands.rst b/doc/source/commands.rst index eca6a3a..95ae748 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -1,5 +1,5 @@ -.. SPDX-FileCopyrightText: 2018-2021 kaliko -.. SPDX-License-Identifier: GPL-3.0-or-later +.. SPDX-FileCopyrightText: 2018-2023 kaliko +.. SPDX-License-Identifier: LGPL-3.0-or-later .. _commands: @@ -13,7 +13,6 @@ Get current available commands: import musicpd print(' '.join([cmd for cmd in musicpd.MPDClient()._commands.keys()])) - -List, last updated for v0.6.0: +List, last updated for v0.8.0: .. literalinclude:: commands.txt diff --git a/doc/source/commands.txt b/doc/source/commands.txt index b9dc4a2..22c2f39 100644 --- a/doc/source/commands.txt +++ b/doc/source/commands.txt @@ -7,7 +7,7 @@ status -> fetch_object stats -> fetch_object == Playback Option Commands -consume -> fetch_nothing +consume -> fetch_nothing crossfade -> fetch_nothing mixrampdb -> fetch_nothing mixrampdelay -> fetch_nothing @@ -133,3 +133,10 @@ commands -> fetch_list notcommands -> fetch_list urlhandlers -> fetch_list decoders -> fetch_plugins + +== Client to Client +subscribe -> self._fetch_nothing, +unsubscribe -> self._fetch_nothing, +channels -> self._fetch_list, +readmessages -> self._fetch_messages, +sendmessage -> self._fetch_nothing, diff --git a/doc/source/conf.py b/doc/source/conf.py index 886811f..71de763 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,6 +1,6 @@ # coding: utf-8 # SPDX-FileCopyrightText: 2018-2021 kaliko -# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-License-Identifier: LGPL-3.0-or-later # # Python MPD Module documentation build configuration file, created by # sphinx-quickstart on Mon Mar 12 14:37:32 2018. @@ -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' diff --git a/doc/source/contribute.rst b/doc/source/contribute.rst index dd1c5c1..06d68dd 100644 --- a/doc/source/contribute.rst +++ b/doc/source/contribute.rst @@ -1,5 +1,5 @@ .. SPDX-FileCopyrightText: 2018-2021 kaliko -.. SPDX-License-Identifier: GPL-3.0-or-later +.. SPDX-License-Identifier: LGPL-3.0-or-later Contributing ============= diff --git a/doc/source/doc.rst b/doc/source/doc.rst index eed237d..22cec3b 100644 --- a/doc/source/doc.rst +++ b/doc/source/doc.rst @@ -1,15 +1,12 @@ -.. SPDX-FileCopyrightText: 2018-2021 kaliko -.. SPDX-License-Identifier: GPL-3.0-or-later +.. SPDX-FileCopyrightText: 2018-2023 kaliko +.. SPDX-License-Identifier: LGPL-3.0-or-later musicpd namespace ================= -.. autodata:: musicpd.CONNECTION_TIMEOUT - -.. autodata:: musicpd.SOCKET_TIMEOUT - -.. autoclass:: musicpd.MPDClient - :members: +.. automodule:: musicpd + :members: + :no-undoc-members: .. vim: spell spelllang=en diff --git a/doc/source/examples.rst b/doc/source/examples.rst new file mode 100644 index 0000000..cf4c916 --- /dev/null +++ b/doc/source/examples.rst @@ -0,0 +1,57 @@ +.. 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: + +.. _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/client.py b/doc/source/examples/client.py new file mode 100644 index 0000000..3598673 --- /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 %(module)-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..3138464 --- /dev/null +++ b/doc/source/examples/connect.py @@ -0,0 +1,29 @@ +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/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/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 df5e6cd..d16644b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,5 +1,5 @@ -.. SPDX-FileCopyrightText: 2018-2021 kaliko -.. SPDX-License-Identifier: GPL-3.0-or-later +.. SPDX-FileCopyrightText: 2018-2023 kaliko +.. SPDX-License-Identifier: LGPL-3.0-or-later .. include:: ../../README.rst @@ -20,7 +20,7 @@ Installation Library overview ----------------- +================ Here is a snippet allowing to list the last modified artists in the media library: .. code:: python3 @@ -46,7 +46,7 @@ Here is a snippet allowing to list the last modified artists in the media librar Build documentation --------------------- +=================== .. code:: bash @@ -55,9 +55,7 @@ Build documentation # Installs sphinx if needed python3 -m venv venv && . ./venv/bin/activate pip install sphinx - # And build - python3 setup.py build_sphinx - # Or call sphinx + # Call sphinx sphinx-build -d ./doc/build/doctrees doc/source -b html ./doc/build/html @@ -67,9 +65,11 @@ Contents .. toctree:: :maxdepth: 2 + self use.rst doc.rst commands.rst + examples.rst contribute.rst diff --git a/doc/source/use.rst b/doc/source/use.rst index 8e9fe47..88728ef 100644 --- a/doc/source/use.rst +++ b/doc/source/use.rst @@ -1,5 +1,5 @@ -.. SPDX-FileCopyrightText: 2018-2021 kaliko -.. SPDX-License-Identifier: GPL-3.0-or-later +.. SPDX-FileCopyrightText: 2018-2023 kaliko +.. SPDX-License-Identifier: LGPL-3.0-or-later Using the client library ========================= @@ -16,43 +16,84 @@ The client library can be used as follows: # test ${XDG_RUNTIME_DIR}/mpd/socket for existence # fallback to localhost:6600 # connect support host/port argument as well - print(client.mpd_version) # print the mpd protocol version - print(client.cmd('foo', 42)) # print result of the request "cmd foo 42" - # (nb. for actual command, see link to the protocol below) + print(client.mpd_version) # print the MPD protocol version + client.setvol('42') # sets the volume client.disconnect() # disconnect from the server -In the example above `cmd` in not an actual MPD command, for a list of -supported commands, their arguments (as MPD currently understands -them), and the functions used to parse their responses see :ref:`commands`. +The MPD command protocol exchanges line-based text records. The client emits a +command with optional arguments. In the example above the client sends a +`setvol` command with the string argument `42`. -See the `MPD protocol documentation`_ for more details. +MPD commands are exposed as :py:class:`musicpd.MPDClient` methods. Methods +**arguments are python strings**. Some commands are composed of more than one word +(ie "**tagtypes [disable|enable|all]**"), for these use a `snake case`_ style to +access the method. Then **"tagtypes enable"** command is called with +**"tagtypes_enable"**. + +Remember MPD protocol is text based, then all MPD command arguments are UTF-8 +strings. In the example above, an integer can be used as argument for the +`setvol` command, but it is then evaluated as a string when the command is +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`. + +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`. .. _environment_variables: Environment variables --------------------- -The client honors the following environment variables: +:py:class:`musicpd.MPDClient` honors the following environment variables: + +.. envvar:: MPD_HOST + + MPD host (:abbr:`FQDN (fully qualified domain name)`, IP, socket path or abstract socket) and password. + + | To define a **password** set :envvar:`MPD_HOST` to "*password@host*" (password only "*password@*") + | For **abstract socket** use "@" as prefix : "*@socket*" and then with a password "*pass@@socket*" + | Regular **unix socket** are set with an absolute path: "*/run/mpd/socket*" + +.. envvar:: MPD_PORT + + MPD port, relevant for TCP socket only - * ``MPD_HOST`` MPD host (:abbr:`FQDN (fully qualified domain name)`, socket path or abstract socket) and password. +.. envvar:: MPD_TIMEOUT - | To define a password set MPD_HOST to "`password@host`" (password only "`password@`") - | For abstract socket use "@" as prefix : "`@socket`" and then with a password "`pass@@socket`" - | Regular unix socket are set with an absolute path: "`/run/mpd/socket`" - * ``MPD_PORT`` MPD port, relevant for TCP socket only, ie with :abbr:`FQDN (fully qualified domain name)` defined host - * ``MPD_TIMEOUT`` timeout for connecting to MPD and waiting for MPD’s response in seconds - * ``XDG_RUNTIME_DIR`` path to look for potential socket: ``${XDG_RUNTIME_DIR}/mpd/socket`` + socket timeout when connecting to MPD and waiting for MPD’s response (in seconds) + +.. envvar:: XDG_RUNTIME_DIR + + path to look for potential socket .. _default_settings: Default settings ---------------- - * If ``MPD_HOST`` is not set, then look for a socket in ``${XDG_RUNTIME_DIR}/mpd/socket`` - * If there is no socket use ``localhost`` - * If ``MPD_PORT`` is not set, then use ``6600`` - * If ``MPD_TIMEOUT`` is not set, then uses :py:obj:`musicpd.CONNECTION_TIMEOUT` +Default host: + * use :envvar:`MPD_HOST` environment variable if set, extract password if present, + * 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 if set + * else use ``6600`` +Default timeout: + * use :envvar:`MPD_TIMEOUT` if set + * else use :py:obj:`musicpd.CONNECTION_TIMEOUT` Context manager --------------- @@ -86,21 +127,24 @@ Command lists are also supported using `command_list_ok_begin()` and Ranges ------ -Provide a 2-tuple as argument for command supporting ranges (cf. `MPD protocol documentation`_ for more details). -Possible ranges are: "START:END", "START:" and ":" : +Some commands (e.g. delete) allow specifying a range in the form `"START:END"` (cf. `MPD protocol documentation`_ for more details). + +Possible ranges are: `"START:END"`, `"START:"` and `":"` : + +Instead of giving the plain string as `"START:END"`, you **can** provide a :py:obj:`tuple` as `(START,END)`. The module is then ensuring the format is correct and raises an :py:obj:`musicpd.CommandError` exception otherwise. Empty start or end can be specified as en empty string ``''`` or :py:obj:`None`. .. code-block:: python # An intelligent clear # clears played track in the queue, currentsong included pos = client.currentsong().get('pos', 0) - # the 2-tuple range object accepts str, no need to convert to int + # the range object accepts str, no need to convert to int client.delete((0, pos)) # missing end interpreted as highest value possible, pay attention still need a tuple. client.delete((pos,)) # purge queue from current to the end -A notable case is the `rangeid` command allowing an empty range specified -as a single colon as argument (i.e. sending just ":"): +A notable case is the *rangeid* command allowing an empty range specified +as a single colon as argument (i.e. sending just ``":"``): .. code-block:: python @@ -109,6 +153,8 @@ 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. +.. note:: Remember the use of a tuple is **optional**. Range can still be specified as a plain string ``"START:END"``. + Iterators ---------- @@ -172,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: @@ -267,6 +313,20 @@ 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:`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. + +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 .. vim: spell spelllang=en diff --git a/musicpd.py b/musicpd.py index b44518b..e24b51d 100644 --- a/musicpd.py +++ b/musicpd.py @@ -1,14 +1,16 @@ -# SPDX-FileCopyrightText: 2012-2022 kaliko +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: 2012-2024 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: GPL-3.0-or-later -"""python-musicpd: Python Music Player Daemon client library""" +# SPDX-License-Identifier: LGPL-3.0-or-later +"""Python Music Player Daemon client library""" -import socket +import logging import os +import socket from functools import wraps @@ -16,13 +18,16 @@ HELLO_PREFIX = "OK MPD " ERROR_PREFIX = "ACK " SUCCESS = "OK" NEXT = "list_OK" -VERSION = '0.8.0b0' +#: Module version +VERSION = '0.9.0' #: Seconds before a connection attempt times out -#: (overriden by MPD_TIMEOUT env. var.) +#: (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__) + def iterator_wrapper(func): """Decorator handling iterate option""" @@ -44,59 +49,73 @@ def iterator_wrapper(func): class MPDError(Exception): - pass + """Main musicpd Exception""" class ConnectionError(MPDError): - pass + """Fatal Connection Error, cannot recover from it.""" class ProtocolError(MPDError): - pass + """Fatal Protocol Error, cannot recover from it""" class CommandError(MPDError): - pass + """Malformed command, socket should be fine, can reuse it""" class CommandListError(MPDError): - pass + """""" class PendingCommandError(MPDError): - pass + """""" class IteratingError(MPDError): - pass + """""" class Range: def __init__(self, tpl): self.tpl = tpl + self.lower = '' + self.upper = '' self._check() def __str__(self): - if len(self.tpl) == 0: - return ':' - if len(self.tpl) == 1: - return '{0}:'.format(self.tpl[0]) - return '{0[0]}:{0[1]}'.format(self.tpl) + return f'{self.lower}:{self.upper}' def __repr__(self): - return 'Range({0})'.format(self.tpl) + return f'Range({self.tpl})' + + def _check_element(self, item): + if item is None or item == '': + return '' + try: + return str(int(item)) + except (TypeError, ValueError) as err: + raise CommandError(f'Not an integer: "{item}"') from err + return item def _check(self): if not isinstance(self.tpl, tuple): raise CommandError('Wrong type, provide a tuple') - if len(self.tpl) not in [0, 1, 2]: - raise CommandError('length not in [0, 1, 2]') - for index in self.tpl: - try: - index = int(index) - except (TypeError, ValueError) as err: - raise CommandError('Not a tuple of int') from err + if len(self.tpl) == 0: + return + if len(self.tpl) == 1: + self.lower = self._check_element(self.tpl[0]) + return + if len(self.tpl) != 2: + raise CommandError('Range wrong size (0, 1 or 2 allowed)') + self.lower = self._check_element(self.tpl[0]) + self.upper = self._check_element(self.tpl[1]) + if self.lower == '' and self.upper != '': + raise CommandError(f'Integer expected to start the range: {self.tpl}') + if self.upper.isdigit() and self.lower.isdigit(): + if int(self.lower) > int(self.upper): + raise CommandError(f'Wrong range: {self.lower} > {self.upper}') class _NotConnected: @@ -109,14 +128,15 @@ class _NotConnected: class MPDClient: - """MPDClient instance will look for ``MPD_HOST``/``MPD_PORT``/``XDG_RUNTIME_DIR`` environment - variables and set instance attribute ``host``, ``port`` and ``pwd`` - accordingly. Regarding ``MPD_HOST`` format to expose password refer - MPD client manual :manpage:`mpc (1)`. + """MPDClient instance will look for :envvar:`MPD_HOST`/:envvar:`MPD_PORT`/:envvar:`XDG_RUNTIME_DIR` environment + variables and set instance attribute :py:attr:`host`, :py:attr:`port` and :py:obj:`pwd` + accordingly. - Then :py:obj:`musicpd.MPDClient.connect` will use ``host`` and ``port`` as defaults if not provided as args. + Then :py:obj:`musicpd.MPDClient.connect` will use :py:obj:`host` and + :py:obj:`port` as defaults if not provided as args. - Cf. :py:obj:`musicpd.MPDClient.connect` for details. + Regarding :envvar:`MPD_HOST` format to expose password refer this module + documentation or MPD client manual :manpage:`mpc (1)`. >>> from os import environ >>> environ['MPD_HOST'] = 'pass@mpdhost' @@ -127,22 +147,29 @@ class MPDClient: True >>> cli.connect() # will use host/port as set in MPD_HOST/MPD_PORT - :ivar str host: host used with the current connection - :ivar str,int port: port used with the current connection - :ivar str pwd: password detected in ``MPD_HOST`` environment variable + .. note:: + + default host: + * use :envvar:`MPD_HOST` environment variable if set, extract password if present, + * else use :envvar:`XDG_RUNTIME_DIR` to looks for an existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket`` + * else set host to ``localhost`` + + default port: + * use :envvar:`MPD_PORT` environment variable is set + * else use ``6600`` - .. warning:: Instance attribute host/port/pwd + .. warning:: **Instance attribute host/port/pwd** - While :py:attr:`musicpd.MPDClient().host` and - :py:attr:`musicpd.MPDClient().port` keep track of current connection - host and port, :py:attr:`musicpd.MPDClient().pwd` is set once with + While :py:attr:`musicpd.MPDClient.host` and + :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:`musicpd.MPDClient().password()` with a new password - won't update :py:attr:`musicpd.MPDClient().pwd` value. + 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 ``MPD_HOST`` environment variable, it - will not be used as default value for the :py:meth:`password` method + 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 MPD's password command. """ def __init__(self): @@ -152,6 +179,10 @@ class MPDClient: #: Current connection timeout value, defaults to #: :py:obj:`CONNECTION_TIMEOUT` or env. var. ``MPD_TIMEOUT`` if provided self.mpd_timeout = None + self.mpd_version = '' + """Protocol version as exposed by the server as a :py:obj:`str` + + .. note:: This is the version of the protocol spoken, not the real version of the daemon.""" self._reset() self._commands = { # Status Commands @@ -284,47 +315,58 @@ class MPDClient: "readmessages": self._fetch_messages, "sendmessage": self._fetch_nothing, } + #: host used with the current connection (:py:obj:`str`) + self.host = None + #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`) + self.pwd = None + #: port used with the current connection (:py:obj:`int`, :py:obj:`str`) + self.port = None self._get_envvars() def _get_envvars(self): """ - 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 + Retrieve MPD env. var. to overrides default "localhost:6600" """ + # Set some defaults self.host = 'localhost' - self.pwd = None self.port = os.getenv('MPD_PORT', '6600') - if os.getenv('MPD_HOST'): + _host = 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 + log.debug('password detected in MPD_HOST, set client pwd attribute') self.pwd = mpd_host_env[0] if mpd_host_env[1]: self.host = mpd_host_env[1] + log.debug('host detected in MPD_HOST: %s', self.host) elif mpd_host_env[1]: # No password set but leading @ is an abstract socket self.host = '@'+mpd_host_env[1] + log.debug('host detected in MPD_HOST: %s (abstract socket)', self.host) else: # MPD_HOST is a plain host - self.host = os.getenv('MPD_HOST') + self.host = _host + log.debug('host detected in MPD_HOST: %s', self.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) + log.debug('host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', self.host) + _mpd_timeout = os.getenv('MPD_TIMEOUT', '') + if _mpd_timeout.isdigit(): + self.mpd_timeout = int(_mpd_timeout) + log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout) else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage self.mpd_timeout = CONNECTION_TIMEOUT def __getattr__(self, attr): if attr == 'send_noidle': # have send_noidle to cancel idle as well as noidle - return self.noidle() + return self.noidle if attr.startswith("send_"): command = attr.replace("send_", "", 1) wrapper = self._send @@ -394,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)) @@ -585,7 +627,7 @@ class MPDClient: 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 @@ -600,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) from socket_err return sock def _connect_tcp(self, host, port): @@ -612,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) 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) raise ConnectionError("getaddrinfo returns an empty list") def noidle(self): @@ -642,29 +693,19 @@ class MPDClient: def connect(self, host=None, port=None): """Connects the MPD server - :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details) - :param port: port number (defaults to 6600) + :param str host: hostname, IP or FQDN (defaults to *localhost* or socket) + :param port: port number (defaults to *6600*) :type port: str or int - The connect method honors MPD_HOST/MPD_PORT environment variables. + If host/port are :py:obj:`None` the socket uses :py:attr:`host`/:py:attr:`port` + attributes as defaults. Cf. :py:obj:`MPDClient` for the logic behind default host/port. - The underlying socket also honors MPD_TIMEOUT environment variable + The underlying socket also honors :envvar:`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 an existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket`` - * else set host to ``localhost`` - - If port evaluate to :py:obj:`False` - * if ``MPD_PORT`` environment variable is set, use it for port - * else use ``6600`` """ if not host: host = self.host @@ -677,8 +718,10 @@ class MPDClient: if self._sock is not None: raise ConnectionError("Already connected") if host[0] in ['/', '@']: + log.debug('Connecting unix socket %s', host) self._sock = self._connect_unix(host) else: + log.debug('Connecting tcp socket %s:%s (timeout: %ss)', host, port, self.mpd_timeout) self._sock = self._connect_tcp(host, port) self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape') self._rbfile = self._sock.makefile("rb") @@ -688,19 +731,30 @@ class MPDClient: except: self.disconnect() raise + log.debug('Connected') @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 (integer > 0) + :type: int or None + """ 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a3f46bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2023 kaliko +# SPDX-License-Identifier: LGPL-3.0-or-later +[project] +name = "python-musicpd" +keywords = ["mpd", "Music Player Daemon"] +description = "An MPD (Music Player Daemon) client library written in pure Python." +authors = [ + { name="Kaliko Jack", email="kaliko@azylum.org" }, +] +license = {file = "LICENSE.txt"} +readme = "README.rst" +requires-python = ">=3.6" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dynamic = ["version"] + +[project.optional-dependencies] +sphinx = ["Sphinx>=5.3.0"] + +[project.urls] +"Homepage" = "https://kaliko.me/python-musicpd/" + +[build-system] +requires = ["setuptools>=61.0.0"] + +[tool.setuptools] +py-modules = ["musicpd"] + +[tool.setuptools.dynamic] +version = {attr = "musicpd.VERSION"} diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index dddaf5e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,41 +0,0 @@ -# SPDX-FileCopyrightText: 2012-2021 kaliko -# SPDX-License-Identifier: GPL-3.0-or-later -[sdist] -formats = gztar,zip - -[metadata] -name = python-musicpd -description = An MPD (Music Player Daemon) client library written in pure Python. -version = attr: musicpd.VERSION -long_description = file: README.rst -long_description_content_type = text/x-rst -license = LGPLv3 -license_file = LICENSE.txt -author = Kaliko Jack -author_email = kaliko@azylum.org -url = https://kaliko.me/python-musicpd/ -download_url = https://pypi.org/project/python-musicpd/ -keywords = mpd, Music Player Daemon -classifiers = - Development Status :: 5 - Production/Stable - Intended Audience :: Developers - License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL) - Natural Language :: English - Operating System :: OS Independent - Programming Language :: Python :: 3 - Topic :: Software Development :: Libraries :: Python Modules - -[options] -python_requires = >=3.6 -py_modules = musicpd - -[bdist_wheel] -universal = 0 - -[build_sphinx] -source-dir = doc/source -build-dir = build -all_files = 1 - -[pycodestyle] -max_line_length = 88 diff --git a/setup.py b/setup.py deleted file mode 100755 index 84eaff9..0000000 --- a/setup.py +++ /dev/null @@ -1,10 +0,0 @@ -#! /usr/bin/env python3 -# coding: utf-8 -# SPDX-FileCopyrightText: 2012-2021 kaliko -# SPDX-License-Identifier: GPL-3.0-or-later - -from setuptools import setup - -setup() - -# vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: diff --git a/test.py b/test.py index 5215917..299b54a 100755 --- a/test.py +++ b/test.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # coding: utf-8 -# SPDX-FileCopyrightText: 2012-2021 kaliko -# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2012-2024 kaliko +# SPDX-License-Identifier: LGPL-3.0-or-later # pylint: disable=missing-docstring """ Test suite highly borrowed^Wsteal from python-mpd2 [0] project. @@ -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 @@ -419,6 +420,12 @@ class TestMPDClient(unittest.TestCase): self.client.send_status() self.assertRaises(musicpd.CommandError, self.client.noidle) + def test_send_noidle_calls_noidle(self): + self.MPDWillReturn('OK\n') # nothing changed after idle + self.client.send_idle() + self.client.send_noidle() + self.assertMPDReceived('noidle\n') + def test_client_to_client(self): self.MPDWillReturn('OK\n') self.assertIsNone(self.client.subscribe("monty")) @@ -553,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: @@ -582,10 +589,97 @@ 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 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('[Errno 42] err 42', str(cme.exception)) -class testException(unittest.TestCase): - - def test_CommandError_on_newline(self): + 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('[Errno 42] 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('[Errno 42] 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') @@ -608,5 +702,35 @@ class testContextManager(unittest.TestCase): sock.close.assert_not_called() sock.close.assert_called() +class testRange(unittest.TestCase): + + def test_range(self): + tests = [ + ((), ':'), + ((None,None), ':'), + (('',''), ':'), + (('',), ':'), + ((42,42), '42:42'), + ((42,), '42:'), + (('42',), '42:'), + (('42',None), '42:'), + (('42',''), '42:'), + ] + for tpl, result in tests: + self.assertEqual(str(musicpd.Range(tpl)), result) + with self.assertRaises(musicpd.CommandError): + #CommandError: Integer expected to start the range: (None, 42) + musicpd.Range((None,'42')) + with self.assertRaises(musicpd.CommandError): + # CommandError: Not an integer: "foo" + musicpd.Range(('foo',)) + with self.assertRaises(musicpd.CommandError): + # CommandError: Wrong range: 42 > 41 + musicpd.Range(('42',41)) + with self.assertRaises(musicpd.CommandError): + # CommandError: Wrong range: 42 > 41 + musicpd.Range(('42','42','42')) + + if __name__ == '__main__': unittest.main()