]> kaliko git repositories - python-musicpd.git/commitdiff
Releasing 0.9.0 dev master v0.9.0
authorKaliko Jack <kaliko@azylum.org>
Wed, 10 Apr 2024 16:14:21 +0000 (18:14 +0200)
committerKaliko Jack <kaliko@azylum.org>
Fri, 12 Apr 2024 17:22:40 +0000 (19:22 +0200)
25 files changed:
.gitignore
.gitlab-ci.yml
.travis.yml [deleted file]
CHANGES.txt
MANIFEST.in
README.rst
doc/source/commands.rst
doc/source/commands.txt
doc/source/conf.py
doc/source/contribute.rst
doc/source/doc.rst
doc/source/examples.rst [new file with mode: 0644]
doc/source/examples/client.py [new file with mode: 0644]
doc/source/examples/connect.py [new file with mode: 0644]
doc/source/examples/connect_host.py [new file with mode: 0644]
doc/source/examples/exceptions.py [new file with mode: 0644]
doc/source/examples/findadd.py [new file with mode: 0644]
doc/source/examples/playback.py [new file with mode: 0644]
doc/source/index.rst
doc/source/use.rst
musicpd.py
pyproject.toml [new file with mode: 0644]
setup.cfg [deleted file]
setup.py [deleted file]
test.py

index 3ca37efa70a341a70498a88100e790bd82d52882..705efe3aa3888d13de4b89ba8f32d508c7747ae2 100644 (file)
@@ -1,5 +1,5 @@
 # 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.
index de4dc95418b998ee1b2f4c41938890a67ea3fb45..6576783140e77d3886d75423817f39e198c2223f 100644 (file)
@@ -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 (file)
index b00b506..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-language: python
-
-branches:
-  only:
-    - dev
-
-python:
-  - "3.7"
-  - "3.8"
-  - "3.9"
-
-script: python3 setup.py test
index 4b0648d5b7d3012dff5c922e7c47cc178cb8ed68..fe285d3351d2c75a2b59a9e9793cc254b92512ff 100644 (file)
@@ -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
 ----------------
 
index fd43acaa39983525a8709f494220ffd49370a8c6..8f82cfa63209ac63178c07b038b6eb7dbc86986d 100644 (file)
@@ -1,2 +1,3 @@
 include CHANGES.txt
-recursive-include doc/source *
+graft doc/source
+global-exclude *~ *.py[cod] *.so
index 88c743027c9c0059abd6f8885af2dac377b797a7..825dd160a3b86272e5209bc8cb75f37ac33b4d21 100644 (file)
@@ -1,5 +1,5 @@
-Python MusicPlayerDaemon client module
-***************************************
+Music Player Daemon client module
+*********************************
 
 An MPD (Music Player Daemon) client library written in pure Python.
 
index eca6a3ac5b5f5efe41156c473f816e2ab733c366..95ae7489ec5d5a3e7985bfad6163d0001eb0e504 100644 (file)
@@ -1,5 +1,5 @@
-.. SPDX-FileCopyrightText: 2018-202 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:
 
@@ -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
index b9dc4a2b7af7e2c038ae08ff030218c84c9cf854..22c2f39af1513e361ab9351ef762fb6721ea00d2 100644 (file)
@@ -7,7 +7,7 @@ status                                     -> fetch_object
 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
@@ -133,3 +133,10 @@ commands         -> fetch_list
 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,
index 886811f3c596e33db0cd1feae63d0985b48469a1..71de7639ed6d388a6a84a44deccfd29e46058d33 100644 (file)
@@ -1,6 +1,6 @@
 # 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.
@@ -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'
index dd1c5c122a6f6751ed06618429880b3325c63861..06d68dd80c9b49a3dec03e21a99bb7db896e39b5 100644 (file)
@@ -1,5 +1,5 @@
 .. 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
 =============
index eed237de75575fbdc182a49a2b967d49b2c1cebf..22cec3bbfaabb92ca5d08d4414c291b6e3e12e3d 100644 (file)
@@ -1,15 +1,12 @@
-.. SPDX-FileCopyrightText: 2018-202 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
diff --git a/doc/source/examples.rst b/doc/source/examples.rst
new file mode 100644 (file)
index 0000000..cf4c916
--- /dev/null
@@ -0,0 +1,57 @@
+.. 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:
diff --git a/doc/source/examples/client.py b/doc/source/examples/client.py
new file mode 100644 (file)
index 0000000..3598673
--- /dev/null
@@ -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 (file)
index 0000000..3138464
--- /dev/null
@@ -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 (file)
index 0000000..a7bdccc
--- /dev/null
@@ -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 (file)
index 0000000..1d10c32
--- /dev/null
@@ -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 (file)
index 0000000..922cae1
--- /dev/null
@@ -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 (file)
index 0000000..ce7eb78
--- /dev/null
@@ -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')
index df5e6cd18a132d4a3a3b6c41b934f2676e9a06b3..d16644bf2488e33129453af7f60fe1c7f222996a 100644 (file)
@@ -1,5 +1,5 @@
-.. 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
 
@@ -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
 
 
index 8e9fe4793cc7e3c67d55dcca6cb1fafa0fa2d281..88728ef9eb6043690996e2d924eee70a1c670c1e 100644 (file)
@@ -1,5 +1,5 @@
-.. 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
 =========================
@@ -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<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
index b44518b9837954ee775ae3f9d844d34888b4dd62..e24b51d85cc2f3f672172cd034b2bc7b9caea402 100644 (file)
@@ -1,14 +1,16 @@
-# 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
 
@@ -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 (file)
index 0000000..a3f46bd
--- /dev/null
@@ -0,0 +1,37 @@
+# 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"}
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644 (file)
index dddaf5e..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,41 +0,0 @@
-# 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
diff --git a/setup.py b/setup.py
deleted file mode 100755 (executable)
index 84eaff9..0000000
--- a/setup.py
+++ /dev/null
@@ -1,10 +0,0 @@
-#! /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:
diff --git a/test.py b/test.py
index 5215917ce898c838c2c53c3cb81f5e7ad42bbd23..299b54a85669c4fa6574d911e90da45ac3cee304 100755 (executable)
--- a/test.py
+++ b/test.py
@@ -1,7 +1,7 @@
 #!/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.
@@ -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()