# SPDX-FileCopyrightText: 2012-2022 kaliko <kaliko@azylum.org>
-# SPDX-License-Identifier: GPL-3.0-or-later
+# SPDX-License-Identifier: LGPL-3.0-or-later
# python
__pycache__
*.pyc.
+---
image: python:latest
before_script:
- 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"
+++ /dev/null
-language: python
-
-branches:
- only:
- - dev
-
-python:
- - "3.7"
- - "3.8"
- - "3.9"
-
-script: python3 setup.py test
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
----------------
include CHANGES.txt
-recursive-include doc/source *
+graft doc/source
+global-exclude *~ *.py[cod] *.so
-Python MusicPlayerDaemon client module
-***************************************
+Music Player Daemon client module
+*********************************
An MPD (Music Player Daemon) client library written in pure Python.
-.. SPDX-FileCopyrightText: 2018-2021 kaliko <kaliko@azylum.org>
-.. SPDX-License-Identifier: GPL-3.0-or-later
+.. SPDX-FileCopyrightText: 2018-2023 kaliko <kaliko@azylum.org>
+.. SPDX-License-Identifier: LGPL-3.0-or-later
.. _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
stats -> fetch_object
== Playback Option Commands
-consume <bool> -> fetch_nothing
+consume <str> -> fetch_nothing
crossfade <int> -> fetch_nothing
mixrampdb <str> -> fetch_nothing
mixrampdelay <int> -> fetch_nothing
notcommands -> fetch_list
urlhandlers -> fetch_list
decoders -> fetch_plugins
+
+== Client to Client
+subscribe <str> -> self._fetch_nothing,
+unsubscribe <str> -> self._fetch_nothing,
+channels -> self._fetch_list,
+readmessages -> self._fetch_messages,
+sendmessage <str> <str> -> self._fetch_nothing,
# coding: utf-8
# SPDX-FileCopyrightText: 2018-2021 kaliko <kaliko@azylum.org>
-# 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.
#
# 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:
# 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'
.. SPDX-FileCopyrightText: 2018-2021 kaliko <kaliko@azylum.org>
-.. SPDX-License-Identifier: GPL-3.0-or-later
+.. SPDX-License-Identifier: LGPL-3.0-or-later
Contributing
=============
-.. SPDX-FileCopyrightText: 2018-2021 kaliko <kaliko@azylum.org>
-.. SPDX-License-Identifier: GPL-3.0-or-later
+.. SPDX-FileCopyrightText: 2018-2023 kaliko <kaliko@azylum.org>
+.. 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
--- /dev/null
+.. SPDX-FileCopyrightText: 2018-2023 kaliko <kaliko@azylum.org>
+.. 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<musicpd.MPDError>`
+exceptions **and** python :py:obj:`OSError`. Then you can wrap
+:py:obj:`OSError` in :py:obj:`MPDError<musicpd.MPDError>` exceptions to have to deal
+with a single type of exceptions in your code:
+
+.. literalinclude:: examples/exceptions.py
+ :language: python
+ :linenos:
--- /dev/null
+"""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()
+
--- /dev/null
+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()
--- /dev/null
+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()
--- /dev/null
+"""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
--- /dev/null
+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()
--- /dev/null
+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')
-.. SPDX-FileCopyrightText: 2018-2021 kaliko <kaliko@azylum.org>
-.. SPDX-License-Identifier: GPL-3.0-or-later
+.. SPDX-FileCopyrightText: 2018-2023 kaliko <kaliko@azylum.org>
+.. SPDX-License-Identifier: LGPL-3.0-or-later
.. include:: ../../README.rst
Library overview
-----------------
+================
Here is a snippet allowing to list the last modified artists in the media library:
.. code:: python3
Build documentation
---------------------
+===================
.. code:: bash
# 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
.. toctree::
:maxdepth: 2
+ self
use.rst
doc.rst
commands.rst
+ examples.rst
contribute.rst
-.. SPDX-FileCopyrightText: 2018-2021 kaliko <kaliko@azylum.org>
-.. SPDX-License-Identifier: GPL-3.0-or-later
+.. SPDX-FileCopyrightText: 2018-2023 kaliko <kaliko@azylum.org>
+.. SPDX-License-Identifier: LGPL-3.0-or-later
Using the client library
=========================
# 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
---------------
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
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
----------
>>> 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:
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<musicpd.MPDClient.connect>` method raises
+:py:obj:`ConnectionError<musicpd.ConnectionError>` only (an :py:obj:`MPDError<musicpd.MPDError>` exception) but then, calling other MPD commands, the module can raise
+:py:obj:`MPDError<musicpd.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<exceptions_example>` 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
-# SPDX-FileCopyrightText: 2012-2022 kaliko <kaliko@azylum.org>
+# -*- coding: utf-8 -*-
+# SPDX-FileCopyrightText: 2012-2024 kaliko <kaliko@azylum.org>
# SPDX-FileCopyrightText: 2021 Wonko der Verständige <wonko@hanstool.org>
# SPDX-FileCopyrightText: 2019 Naglis Jonaitis <naglis@mailbox.org>
# SPDX-FileCopyrightText: 2019 Bart Van Loon <bbb@bbbart.be>
# SPDX-FileCopyrightText: 2008-2010 J. Alexander Treuman <jat@spatialrift.net>
-# 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
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"""
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:
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'
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):
#: 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
"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
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))
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
# 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):
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):
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
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")
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
--- /dev/null
+# SPDX-FileCopyrightText: 2023 kaliko <kaliko@azylum.org>
+# 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"}
+++ /dev/null
-# SPDX-FileCopyrightText: 2012-2021 kaliko <kaliko@azylum.org>
-# 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
+++ /dev/null
-#! /usr/bin/env python3
-# coding: utf-8
-# SPDX-FileCopyrightText: 2012-2021 kaliko <kaliko@azylum.org>
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-from setuptools import setup
-
-setup()
-
-# vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79:
#!/usr/bin/env python3
# coding: utf-8
-# SPDX-FileCopyrightText: 2012-2021 kaliko <kaliko@azylum.org>
-# SPDX-License-Identifier: GPL-3.0-or-later
+# SPDX-FileCopyrightText: 2012-2024 kaliko <kaliko@azylum.org>
+# SPDX-License-Identifier: LGPL-3.0-or-later
# pylint: disable=missing-docstring
"""
Test suite highly borrowed^Wsteal from python-mpd2 [0] project.
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')
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
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"))
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:
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')
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()