python-musicpd 0.9.2__tar.gz → 0.9.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/CHANGES.txt +7 -0
  2. {python_musicpd-0.9.2/python_musicpd.egg-info → python_musicpd-0.9.3}/PKG-INFO +4 -4
  3. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/README.rst +1 -1
  4. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/commands.txt +1 -1
  5. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/conf.py +8 -8
  6. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/examples/musicpd.py +107 -41
  7. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/use.rst +24 -0
  8. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/musicpd.py +107 -41
  9. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/pyproject.toml +12 -3
  10. {python_musicpd-0.9.2 → python_musicpd-0.9.3/python_musicpd.egg-info}/PKG-INFO +4 -4
  11. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/python_musicpd.egg-info/requires.txt +1 -0
  12. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/LICENSE.txt +0 -0
  13. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/MANIFEST.in +0 -0
  14. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/_commands.rst +0 -0
  15. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/commands.rst +0 -0
  16. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/contribute.rst +0 -0
  17. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/doc.rst +0 -0
  18. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/examples/client.py +0 -0
  19. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/examples/connect.py +0 -0
  20. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/examples/connect_host.py +0 -0
  21. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/examples/exceptions.py +0 -0
  22. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/examples/findadd.py +0 -0
  23. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/examples/playback.py +0 -0
  24. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/examples.rst +0 -0
  25. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/index.rst +0 -0
  26. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/doc/source/python-musicpd.svg +0 -0
  27. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/python_musicpd.egg-info/SOURCES.txt +0 -0
  28. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/python_musicpd.egg-info/dependency_links.txt +0 -0
  29. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/python_musicpd.egg-info/top_level.txt +0 -0
  30. {python_musicpd-0.9.2 → python_musicpd-0.9.3}/setup.cfg +0 -0
@@ -1,6 +1,13 @@
1
1
  python-musicpd Changes List
2
2
  ===========================
3
3
 
4
+ Changes in 0.9.3
5
+ ----------------
6
+
7
+ * Add support for the list command grouping (Group marker)
8
+ * Fix deprecation in pyproject.toml
9
+ * Minimum python version raised to 3.11
10
+
4
11
  Changes in 0.9.2
5
12
  ----------------
6
13
 
@@ -1,9 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-musicpd
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: An MPD (Music Player Daemon) client library written in pure Python.
5
5
  Author-email: Kaliko Jack <kaliko@azylum.org>
6
- License-Expression: LGPL-3.0-or-later
7
6
  Project-URL: Homepage, https://kaliko.me/python-musicpd/
8
7
  Keywords: mpd,Music Player Daemon
9
8
  Classifier: Development Status :: 5 - Production/Stable
@@ -12,11 +11,12 @@ Classifier: Natural Language :: English
12
11
  Classifier: Operating System :: OS Independent
13
12
  Classifier: Programming Language :: Python :: 3
14
13
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
- Requires-Python: >=3.6
14
+ Requires-Python: >=3.11
16
15
  Description-Content-Type: text/x-rst
17
16
  License-File: LICENSE.txt
18
17
  Provides-Extra: sphinx
19
18
  Requires-Dist: Sphinx>=5.3.0; extra == "sphinx"
19
+ Requires-Dist: sphinx-rtd-theme>=3.1.0; extra == "sphinx"
20
20
  Dynamic: license-file
21
21
 
22
22
  Music Player Daemon client module
@@ -30,5 +30,5 @@ An MPD (Music Player Daemon) client library written in pure Python.
30
30
  :MPD Protocol: https://www.musicpd.org/doc/html/protocol.html
31
31
  :Code: https://gitlab.com/kaliko/python-musicpd
32
32
  :Dependencies: None
33
- :Compatibility: Python 3.6+
33
+ :Compatibility: Python 3.11+
34
34
  :Licence: GNU LGPLv3
@@ -9,5 +9,5 @@ An MPD (Music Player Daemon) client library written in pure Python.
9
9
  :MPD Protocol: https://www.musicpd.org/doc/html/protocol.html
10
10
  :Code: https://gitlab.com/kaliko/python-musicpd
11
11
  :Dependencies: None
12
- :Compatibility: Python 3.6+
12
+ :Compatibility: Python 3.11+
13
13
  :Licence: GNU LGPLv3
@@ -75,7 +75,7 @@ count group <str> -> fetch_object
75
75
  getfingerprint <str> -> fetch_object
76
76
  find <str> <str> [<str> <str>]... -> fetch_songs
77
77
  findadd <str> <str> [<str> <str>] -> fetch_nothing
78
- list <str> [<str> <str>]...[group <str>]... -> fetch_list
78
+ list <str> [<str> <str>]...[<Group>]... -> fetch_list
79
79
  listall [<str>] -> fetch_database
80
80
  listallinfo [<str>] -> fetch_database
81
81
  listfiles <str> -> fetch_database
@@ -1,5 +1,5 @@
1
1
  # coding: utf-8
2
- # SPDX-FileCopyrightText: 2018-2021 kaliko <kaliko@azylum.org>
2
+ # SPDX-FileCopyrightText: 2018-2026 kaliko <kaliko@azylum.org>
3
3
  # SPDX-License-Identifier: LGPL-3.0-or-later
4
4
  #
5
5
  # Python MPD Module documentation build configuration file, created by
@@ -57,9 +57,9 @@ source_suffix = '.rst'
57
57
  master_doc = 'index'
58
58
 
59
59
  # General information about the project.
60
- project = u'Python MPD Module'
60
+ project = 'Python MPD Module'
61
61
  from datetime import datetime
62
- copyright = u'2012-{}, {}'.format(datetime.now().year, __author__)
62
+ copyright = '2012-{}, {}'.format(datetime.now().year, __author__)
63
63
  author = __author__
64
64
 
65
65
  # The version info for the project you're documenting, acts as replacement for
@@ -146,7 +146,7 @@ html_theme = 'classic'
146
146
  # The name for this set of Sphinx documents.
147
147
  # "<project> v<release> documentation" by default.
148
148
  #
149
- # html_title = u'Python MPD Module v0.0.1'
149
+ # html_title = 'Python MPD Module v0.0.1'
150
150
 
151
151
  # A shorter title for the navigation bar. Default is the same as html_title.
152
152
  #
@@ -273,8 +273,8 @@ latex_elements = {
273
273
  # (source start file, target name, title,
274
274
  # author, documentclass [howto, manual, or own class]).
275
275
  latex_documents = [
276
- (master_doc, 'PythonMPDModule.tex', u'Python MPD Module Documentation',
277
- u'kaliko', 'manual'),
276
+ (master_doc, 'PythonMPDModule.tex', 'Python MPD Module Documentation',
277
+ 'kaliko', 'manual'),
278
278
  ]
279
279
 
280
280
  # The name of an image file (relative to this directory) to place at the top of
@@ -315,7 +315,7 @@ latex_documents = [
315
315
  # One entry per manual page. List of tuples
316
316
  # (source start file, name, description, authors, manual section).
317
317
  man_pages = [
318
- (master_doc, 'pythonmpdmodule', u'Python MPD Module Documentation',
318
+ (master_doc, 'pythonmpdmodule', 'Python MPD Module Documentation',
319
319
  [author], 1)
320
320
  ]
321
321
 
@@ -330,7 +330,7 @@ man_pages = [
330
330
  # (source start file, target name, title, author,
331
331
  # dir menu entry, description, category)
332
332
  texinfo_documents = [
333
- (master_doc, 'PythonMPDModule', u'Python MPD Module Documentation',
333
+ (master_doc, 'PythonMPDModule', 'Python MPD Module Documentation',
334
334
  author, 'PythonMPDModule', 'One line description of project.',
335
335
  'Miscellaneous'),
336
336
  ]
@@ -1,5 +1,4 @@
1
- # -*- coding: utf-8 -*-
2
- # SPDX-FileCopyrightText: 2012-2025 kaliko <kaliko@azylum.org>
1
+ # SPDX-FileCopyrightText: 2012-2026 kaliko <kaliko@azylum.org>
3
2
  # SPDX-FileCopyrightText: 2021 Wonko der Verständige <wonko@hanstool.org>
4
3
  # SPDX-FileCopyrightText: 2019 Naglis Jonaitis <naglis@mailbox.org>
5
4
  # SPDX-FileCopyrightText: 2019 Bart Van Loon <bbb@bbbart.be>
@@ -11,7 +10,6 @@
11
10
  import logging
12
11
  import os
13
12
  import socket
14
-
15
13
  from functools import wraps
16
14
 
17
15
  HELLO_PREFIX = "OK MPD "
@@ -19,14 +17,14 @@ ERROR_PREFIX = "ACK "
19
17
  SUCCESS = "OK"
20
18
  NEXT = "list_OK"
21
19
  #: Module version
22
- VERSION = '0.9.2'
20
+ VERSION = '0.9.3'
23
21
  #: Seconds before a connection attempt times out
24
22
  #: (overriden by :envvar:`MPD_TIMEOUT` env. var.)
25
23
  CONNECTION_TIMEOUT = 30
26
24
  #: Socket timeout in second > 0 (Default is :py:obj:`None` for no timeout)
27
25
  SOCKET_TIMEOUT = None
28
26
 
29
- log = logging.getLogger(__name__)
27
+ logger = logging.getLogger(__name__)
30
28
 
31
29
 
32
30
  def iterator_wrapper(func):
@@ -40,8 +38,7 @@ def iterator_wrapper(func):
40
38
 
41
39
  def iterator(gen):
42
40
  try:
43
- for item in gen:
44
- yield item
41
+ yield from gen
45
42
  finally:
46
43
  instance._iterating = False
47
44
  return iterator(generator)
@@ -97,7 +94,6 @@ class Range:
97
94
  return str(int(item))
98
95
  except (TypeError, ValueError) as err:
99
96
  raise CommandError(f'Not an integer: "{item}"') from err
100
- return item
101
97
 
102
98
  def _check(self):
103
99
  if not isinstance(self.tpl, tuple):
@@ -118,6 +114,22 @@ class Range:
118
114
  raise CommandError(f'Wrong range: {self.lower} > {self.upper}')
119
115
 
120
116
 
117
+ class Group:
118
+ """Mark a tag used with the ``group`` keyword of the :py:obj:`list` command.
119
+
120
+ >>> client.list('album', '(genre == "Punk")', musicpd.Group('artist'))
121
+ """
122
+
123
+ def __init__(self, tag):
124
+ self.tag = tag
125
+
126
+ def __str__(self):
127
+ return f'group {self.tag}'
128
+
129
+ def __repr__(self):
130
+ return f'Group({self.tag!r})'
131
+
132
+
121
133
  class _NotConnected:
122
134
 
123
135
  def __getattr__(self, attr):
@@ -184,6 +196,7 @@ class MPDClient:
184
196
 
185
197
  .. note:: This is the version of the protocol spoken, not the real version of the daemon."""
186
198
  self._reset()
199
+ # fmt: off
187
200
  self._commands = {
188
201
  # Querying MPD’s status # querying-mpd-s-status
189
202
  "clearerror": self._fetch_nothing,
@@ -327,21 +340,19 @@ class MPDClient:
327
340
  "readmessages": self._fetch_messages,
328
341
  "sendmessage": self._fetch_nothing,
329
342
  }
343
+ # fmt: on
330
344
  #: host used with the current connection (:py:obj:`str`)
331
- self.host = None
345
+ self.host = 'localhost'
332
346
  #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`)
333
347
  self.pwd = None
334
348
  #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
335
- self.port = None
349
+ self.port = os.getenv('MPD_PORT', '6600')
336
350
  self._get_envvars()
337
351
 
338
352
  def _get_envvars(self):
339
353
  """
340
354
  Retrieve MPD env. var. to overrides default "localhost:6600"
341
355
  """
342
- # Set some defaults
343
- self.host = 'localhost'
344
- self.port = os.getenv('MPD_PORT', '6600')
345
356
  _host = os.getenv('MPD_HOST', '')
346
357
  if _host:
347
358
  # If password is set: MPD_HOST=pass@host
@@ -349,30 +360,30 @@ class MPDClient:
349
360
  mpd_host_env = _host.split('@', 1)
350
361
  if mpd_host_env[0]:
351
362
  # A password is actually set
352
- log.debug('password detected in MPD_HOST, set client pwd attribute')
363
+ logger.debug('password detected in MPD_HOST, set client pwd attribute')
353
364
  self.pwd = mpd_host_env[0]
354
365
  if mpd_host_env[1]:
355
366
  self.host = mpd_host_env[1]
356
- log.debug('host detected in MPD_HOST: %s', self.host)
367
+ logger.debug('host detected in MPD_HOST: %s', self.host)
357
368
  elif mpd_host_env[1]:
358
369
  # No password set but leading @ is an abstract socket
359
370
  self.host = '@'+mpd_host_env[1]
360
- log.debug('host detected in MPD_HOST: %s (abstract socket)', self.host)
371
+ logger.debug('host detected in MPD_HOST: %s (abstract socket)', self.host)
361
372
  else:
362
373
  # MPD_HOST is a plain host
363
374
  self.host = _host
364
- log.debug('host detected in MPD_HOST: %s', self.host)
375
+ logger.debug('host detected in MPD_HOST: %s', self.host)
365
376
  else:
366
377
  # Is socket there
367
378
  xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
368
379
  rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
369
380
  if os.path.exists(rundir):
370
381
  self.host = rundir
371
- log.debug('host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', self.host)
382
+ logger.debug('host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', self.host)
372
383
  _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
373
384
  if _mpd_timeout.isdigit():
374
385
  self.mpd_timeout = int(_mpd_timeout)
375
- log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
386
+ logger.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
376
387
  else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
377
388
  self.mpd_timeout = CONNECTION_TIMEOUT
378
389
 
@@ -397,8 +408,7 @@ class MPDClient:
397
408
 
398
409
  def _send(self, command, args):
399
410
  if self._command_list is not None:
400
- raise CommandListError("Cannot use send_%s in a command list" %
401
- command.replace(" ", "_"))
411
+ raise CommandListError("Cannot use send_{} in a command list".format(command.replace(" ", "_")))
402
412
  self._write_command(command, args)
403
413
  retval = self._commands[command]
404
414
  if retval is not None:
@@ -449,6 +459,8 @@ class MPDClient:
449
459
  for arg in args:
450
460
  if isinstance(arg, tuple):
451
461
  parts.append(f'{Range(arg)!s}')
462
+ elif isinstance(arg, Group):
463
+ parts.append(f'group "{escape(str(arg.tag))}"')
452
464
  else:
453
465
  parts.append(f'"{escape(str(arg))}"')
454
466
  if '\n' in ' '.join(parts):
@@ -503,13 +515,67 @@ class MPDClient:
503
515
  pair = self._read_pair(separator, binary=binary)
504
516
 
505
517
  def _read_list(self):
506
- seen = None
507
- for key, value in self._read_pairs():
508
- if key != seen:
509
- if seen is not None:
510
- raise ProtocolError(f"Expected key '{seen}', got '{key}'")
511
- seen = key
512
- yield value
518
+ """Read a ``list``-style response, flat or grouped.
519
+
520
+ Two response shapes are handled:
521
+
522
+ * **flat** — every line has the same key (e.g. ``Album: Foo`` then
523
+ ``Album: Bar``): values are yielded as a plain list.
524
+ * **grouped** — lines carry mixed keys, as returned by the ``list``
525
+ command with the ``group`` keyword. Group tags are only sent when
526
+ their value changes, so each row is rebuilt by propagating the
527
+ current group values.
528
+
529
+ For grouped responses, ``delimiters`` tracks the active tags in order
530
+ of appearance (the group hierarchy, ending with the listed tag). When
531
+ a key already present in the current row is seen, the row is yielded
532
+ and the ``while`` loop pops deeper tags from both the row and
533
+ ``delimiters`` until the changed key is reached, carrying the
534
+ shallower group values over to the next row.
535
+
536
+ Example::
537
+
538
+ Artist: Crass
539
+ Album: Stations
540
+ Album: Feeding
541
+ Artist: The Clash
542
+ Album: London Calling
543
+
544
+ yields::
545
+
546
+ [{'artist': 'Crass', 'album': 'Stations'},
547
+ {'artist': 'Crass', 'album': 'Feeding'},
548
+ {'artist': 'The Clash', 'album': 'London Calling'}]
549
+
550
+ .. note::
551
+ Grouping is detected from the response (mixed keys), not from the
552
+ command arguments, so plain and grouped ``list`` calls share the
553
+ same fetch method.
554
+ """
555
+ pairs = list(self._read_pairs())
556
+ if not pairs:
557
+ return
558
+ if len({key for key, _ in pairs}) == 1:
559
+ for _, value in pairs:
560
+ yield value
561
+ return
562
+ # Grouped list: group tags are only sent when their value changes,
563
+ # hence the dynamic delimiters handling.
564
+ delimiters = []
565
+ obj = {}
566
+ for key, value in pairs:
567
+ key = key.lower()
568
+ if key not in delimiters:
569
+ delimiters.append(key)
570
+ if obj and key in obj:
571
+ yield obj
572
+ obj = obj.copy()
573
+ while delimiters[-1] != key:
574
+ obj.pop(delimiters[-1], None)
575
+ delimiters.pop()
576
+ obj[key] = value
577
+ if obj:
578
+ yield obj
513
579
 
514
580
  def _read_playlist(self):
515
581
  for _, value in self._read_pairs(":"):
@@ -551,7 +617,7 @@ class MPDClient:
551
617
  def _fetch_item(self):
552
618
  pairs = list(self._read_pairs())
553
619
  if len(pairs) != 1:
554
- return None
620
+ raise ProtocolError(f"Expected 1 pair, got {len(pairs)}")
555
621
  return pairs[0][1]
556
622
 
557
623
  @iterator_wrapper
@@ -613,7 +679,7 @@ class MPDClient:
613
679
  amount = int(obj['binary'])
614
680
  try:
615
681
  obj['data'] = self._read_binary(amount)
616
- except IOError as err:
682
+ except OSError as err:
617
683
  raise ConnectionError(f'Error reading binary content: {err}') from err
618
684
  data_bytes = len(obj['data'])
619
685
  if data_bytes != amount: # can we ever get there?
@@ -659,7 +725,7 @@ class MPDClient:
659
725
  sock.settimeout(self.mpd_timeout)
660
726
  sock.connect(path)
661
727
  sock.settimeout(self.socket_timeout)
662
- except socket.error as socket_err:
728
+ except OSError as socket_err:
663
729
  raise ConnectionError(socket_err) from socket_err
664
730
  return sock
665
731
 
@@ -673,20 +739,20 @@ class MPDClient:
673
739
  gai = socket.getaddrinfo(host, port, socket.AF_UNSPEC,
674
740
  socket.SOCK_STREAM, socket.IPPROTO_TCP,
675
741
  flags)
676
- except socket.error as gaierr:
742
+ except OSError as gaierr:
677
743
  raise ConnectionError(gaierr) from gaierr
678
744
  for res in gai:
679
745
  af, socktype, proto, _, sa = res
680
746
  sock = None
681
747
  try:
682
- log.debug('opening socket %s', sa)
748
+ logger.debug('opening socket %s', sa)
683
749
  sock = socket.socket(af, socktype, proto)
684
750
  sock.settimeout(self.mpd_timeout)
685
751
  sock.connect(sa)
686
752
  sock.settimeout(self.socket_timeout)
687
753
  return sock
688
- except socket.error as socket_err:
689
- log.debug('opening socket %s failed: %s', sa, socket_err)
754
+ except OSError as socket_err:
755
+ logger.debug('opening socket %s failed: %s', sa, socket_err)
690
756
  err = socket_err
691
757
  if sock is not None:
692
758
  sock.close()
@@ -723,17 +789,17 @@ class MPDClient:
723
789
  host = self.host
724
790
  else:
725
791
  self.host = host
726
- if not port:
792
+ if port is None:
727
793
  port = self.port
728
794
  else:
729
795
  self.port = port
730
796
  if self._sock is not None:
731
797
  raise ConnectionError("Already connected")
732
- if host[0] in ['/', '@']:
733
- log.debug('Connecting unix socket %s', host)
798
+ if host and host[0] in ['/', '@']:
799
+ logger.debug('Connecting unix socket %s', host)
734
800
  self._sock = self._connect_unix(host)
735
801
  else:
736
- log.debug('Connecting tcp socket %s:%s (timeout: %ss)', host, port, self.mpd_timeout)
802
+ logger.debug('Connecting tcp socket %s:%s (timeout: %ss)', host, port, self.mpd_timeout)
737
803
  self._sock = self._connect_tcp(host, port)
738
804
  self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
739
805
  self._rbfile = self._sock.makefile("rb")
@@ -743,12 +809,12 @@ class MPDClient:
743
809
  except:
744
810
  self.disconnect()
745
811
  raise
746
- log.debug('Connected')
812
+ logger.debug('Connected')
747
813
 
748
814
  @property
749
815
  def socket_timeout(self):
750
816
  """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`).
751
- Use :py:obj:`None` to disable socket timout.
817
+ Use :py:obj:`None` to disable socket timeout.
752
818
 
753
819
  :setter: Set the socket timeout (integer > 0)
754
820
  :type: int or None
@@ -157,6 +157,30 @@ Empty start in range (i.e. ":END") are not possible and will raise a CommandErro
157
157
 
158
158
  .. note:: Remember the use of a tuple is **optional**. Range can still be specified as a plain string ``"START:END"``.
159
159
 
160
+ Grouping list results
161
+ ---------------------
162
+
163
+ The ``list`` command supports the ``group`` keyword to group results by one or
164
+ more tags (cf. `MPD protocol documentation`_). Use the :py:obj:`musicpd.Group`
165
+ marker as argument to send the ``group`` keyword:
166
+
167
+ .. code-block:: python
168
+
169
+ # list albums grouped by artist, filtered on genre
170
+ albums = client.list('album', '(genre == "Punk")', musicpd.Group('artist'))
171
+ # albums is a list of dicts, group values are propagated on each row:
172
+ # [{'artist': 'Ramones', 'album': 'Leave Home'},
173
+ # {'artist': 'The Clash', 'album': 'The Clash'}, ...]
174
+
175
+ The ``group`` keyword can be repeated to group by several tags:
176
+
177
+ .. code-block:: python
178
+
179
+ client.list('album', musicpd.Group('date'), musicpd.Group('artist'))
180
+
181
+ Without any :py:obj:`musicpd.Group` argument, ``list`` still returns a flat
182
+ list of tag values.
183
+
160
184
  Iterators
161
185
  ----------
162
186
 
@@ -1,5 +1,4 @@
1
- # -*- coding: utf-8 -*-
2
- # SPDX-FileCopyrightText: 2012-2025 kaliko <kaliko@azylum.org>
1
+ # SPDX-FileCopyrightText: 2012-2026 kaliko <kaliko@azylum.org>
3
2
  # SPDX-FileCopyrightText: 2021 Wonko der Verständige <wonko@hanstool.org>
4
3
  # SPDX-FileCopyrightText: 2019 Naglis Jonaitis <naglis@mailbox.org>
5
4
  # SPDX-FileCopyrightText: 2019 Bart Van Loon <bbb@bbbart.be>
@@ -11,7 +10,6 @@
11
10
  import logging
12
11
  import os
13
12
  import socket
14
-
15
13
  from functools import wraps
16
14
 
17
15
  HELLO_PREFIX = "OK MPD "
@@ -19,14 +17,14 @@ ERROR_PREFIX = "ACK "
19
17
  SUCCESS = "OK"
20
18
  NEXT = "list_OK"
21
19
  #: Module version
22
- VERSION = '0.9.2'
20
+ VERSION = '0.9.3'
23
21
  #: Seconds before a connection attempt times out
24
22
  #: (overriden by :envvar:`MPD_TIMEOUT` env. var.)
25
23
  CONNECTION_TIMEOUT = 30
26
24
  #: Socket timeout in second > 0 (Default is :py:obj:`None` for no timeout)
27
25
  SOCKET_TIMEOUT = None
28
26
 
29
- log = logging.getLogger(__name__)
27
+ logger = logging.getLogger(__name__)
30
28
 
31
29
 
32
30
  def iterator_wrapper(func):
@@ -40,8 +38,7 @@ def iterator_wrapper(func):
40
38
 
41
39
  def iterator(gen):
42
40
  try:
43
- for item in gen:
44
- yield item
41
+ yield from gen
45
42
  finally:
46
43
  instance._iterating = False
47
44
  return iterator(generator)
@@ -97,7 +94,6 @@ class Range:
97
94
  return str(int(item))
98
95
  except (TypeError, ValueError) as err:
99
96
  raise CommandError(f'Not an integer: "{item}"') from err
100
- return item
101
97
 
102
98
  def _check(self):
103
99
  if not isinstance(self.tpl, tuple):
@@ -118,6 +114,22 @@ class Range:
118
114
  raise CommandError(f'Wrong range: {self.lower} > {self.upper}')
119
115
 
120
116
 
117
+ class Group:
118
+ """Mark a tag used with the ``group`` keyword of the :py:obj:`list` command.
119
+
120
+ >>> client.list('album', '(genre == "Punk")', musicpd.Group('artist'))
121
+ """
122
+
123
+ def __init__(self, tag):
124
+ self.tag = tag
125
+
126
+ def __str__(self):
127
+ return f'group {self.tag}'
128
+
129
+ def __repr__(self):
130
+ return f'Group({self.tag!r})'
131
+
132
+
121
133
  class _NotConnected:
122
134
 
123
135
  def __getattr__(self, attr):
@@ -184,6 +196,7 @@ class MPDClient:
184
196
 
185
197
  .. note:: This is the version of the protocol spoken, not the real version of the daemon."""
186
198
  self._reset()
199
+ # fmt: off
187
200
  self._commands = {
188
201
  # Querying MPD’s status # querying-mpd-s-status
189
202
  "clearerror": self._fetch_nothing,
@@ -327,21 +340,19 @@ class MPDClient:
327
340
  "readmessages": self._fetch_messages,
328
341
  "sendmessage": self._fetch_nothing,
329
342
  }
343
+ # fmt: on
330
344
  #: host used with the current connection (:py:obj:`str`)
331
- self.host = None
345
+ self.host = 'localhost'
332
346
  #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`)
333
347
  self.pwd = None
334
348
  #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
335
- self.port = None
349
+ self.port = os.getenv('MPD_PORT', '6600')
336
350
  self._get_envvars()
337
351
 
338
352
  def _get_envvars(self):
339
353
  """
340
354
  Retrieve MPD env. var. to overrides default "localhost:6600"
341
355
  """
342
- # Set some defaults
343
- self.host = 'localhost'
344
- self.port = os.getenv('MPD_PORT', '6600')
345
356
  _host = os.getenv('MPD_HOST', '')
346
357
  if _host:
347
358
  # If password is set: MPD_HOST=pass@host
@@ -349,30 +360,30 @@ class MPDClient:
349
360
  mpd_host_env = _host.split('@', 1)
350
361
  if mpd_host_env[0]:
351
362
  # A password is actually set
352
- log.debug('password detected in MPD_HOST, set client pwd attribute')
363
+ logger.debug('password detected in MPD_HOST, set client pwd attribute')
353
364
  self.pwd = mpd_host_env[0]
354
365
  if mpd_host_env[1]:
355
366
  self.host = mpd_host_env[1]
356
- log.debug('host detected in MPD_HOST: %s', self.host)
367
+ logger.debug('host detected in MPD_HOST: %s', self.host)
357
368
  elif mpd_host_env[1]:
358
369
  # No password set but leading @ is an abstract socket
359
370
  self.host = '@'+mpd_host_env[1]
360
- log.debug('host detected in MPD_HOST: %s (abstract socket)', self.host)
371
+ logger.debug('host detected in MPD_HOST: %s (abstract socket)', self.host)
361
372
  else:
362
373
  # MPD_HOST is a plain host
363
374
  self.host = _host
364
- log.debug('host detected in MPD_HOST: %s', self.host)
375
+ logger.debug('host detected in MPD_HOST: %s', self.host)
365
376
  else:
366
377
  # Is socket there
367
378
  xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
368
379
  rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
369
380
  if os.path.exists(rundir):
370
381
  self.host = rundir
371
- log.debug('host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', self.host)
382
+ logger.debug('host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', self.host)
372
383
  _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
373
384
  if _mpd_timeout.isdigit():
374
385
  self.mpd_timeout = int(_mpd_timeout)
375
- log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
386
+ logger.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
376
387
  else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
377
388
  self.mpd_timeout = CONNECTION_TIMEOUT
378
389
 
@@ -397,8 +408,7 @@ class MPDClient:
397
408
 
398
409
  def _send(self, command, args):
399
410
  if self._command_list is not None:
400
- raise CommandListError("Cannot use send_%s in a command list" %
401
- command.replace(" ", "_"))
411
+ raise CommandListError("Cannot use send_{} in a command list".format(command.replace(" ", "_")))
402
412
  self._write_command(command, args)
403
413
  retval = self._commands[command]
404
414
  if retval is not None:
@@ -449,6 +459,8 @@ class MPDClient:
449
459
  for arg in args:
450
460
  if isinstance(arg, tuple):
451
461
  parts.append(f'{Range(arg)!s}')
462
+ elif isinstance(arg, Group):
463
+ parts.append(f'group "{escape(str(arg.tag))}"')
452
464
  else:
453
465
  parts.append(f'"{escape(str(arg))}"')
454
466
  if '\n' in ' '.join(parts):
@@ -503,13 +515,67 @@ class MPDClient:
503
515
  pair = self._read_pair(separator, binary=binary)
504
516
 
505
517
  def _read_list(self):
506
- seen = None
507
- for key, value in self._read_pairs():
508
- if key != seen:
509
- if seen is not None:
510
- raise ProtocolError(f"Expected key '{seen}', got '{key}'")
511
- seen = key
512
- yield value
518
+ """Read a ``list``-style response, flat or grouped.
519
+
520
+ Two response shapes are handled:
521
+
522
+ * **flat** — every line has the same key (e.g. ``Album: Foo`` then
523
+ ``Album: Bar``): values are yielded as a plain list.
524
+ * **grouped** — lines carry mixed keys, as returned by the ``list``
525
+ command with the ``group`` keyword. Group tags are only sent when
526
+ their value changes, so each row is rebuilt by propagating the
527
+ current group values.
528
+
529
+ For grouped responses, ``delimiters`` tracks the active tags in order
530
+ of appearance (the group hierarchy, ending with the listed tag). When
531
+ a key already present in the current row is seen, the row is yielded
532
+ and the ``while`` loop pops deeper tags from both the row and
533
+ ``delimiters`` until the changed key is reached, carrying the
534
+ shallower group values over to the next row.
535
+
536
+ Example::
537
+
538
+ Artist: Crass
539
+ Album: Stations
540
+ Album: Feeding
541
+ Artist: The Clash
542
+ Album: London Calling
543
+
544
+ yields::
545
+
546
+ [{'artist': 'Crass', 'album': 'Stations'},
547
+ {'artist': 'Crass', 'album': 'Feeding'},
548
+ {'artist': 'The Clash', 'album': 'London Calling'}]
549
+
550
+ .. note::
551
+ Grouping is detected from the response (mixed keys), not from the
552
+ command arguments, so plain and grouped ``list`` calls share the
553
+ same fetch method.
554
+ """
555
+ pairs = list(self._read_pairs())
556
+ if not pairs:
557
+ return
558
+ if len({key for key, _ in pairs}) == 1:
559
+ for _, value in pairs:
560
+ yield value
561
+ return
562
+ # Grouped list: group tags are only sent when their value changes,
563
+ # hence the dynamic delimiters handling.
564
+ delimiters = []
565
+ obj = {}
566
+ for key, value in pairs:
567
+ key = key.lower()
568
+ if key not in delimiters:
569
+ delimiters.append(key)
570
+ if obj and key in obj:
571
+ yield obj
572
+ obj = obj.copy()
573
+ while delimiters[-1] != key:
574
+ obj.pop(delimiters[-1], None)
575
+ delimiters.pop()
576
+ obj[key] = value
577
+ if obj:
578
+ yield obj
513
579
 
514
580
  def _read_playlist(self):
515
581
  for _, value in self._read_pairs(":"):
@@ -551,7 +617,7 @@ class MPDClient:
551
617
  def _fetch_item(self):
552
618
  pairs = list(self._read_pairs())
553
619
  if len(pairs) != 1:
554
- return None
620
+ raise ProtocolError(f"Expected 1 pair, got {len(pairs)}")
555
621
  return pairs[0][1]
556
622
 
557
623
  @iterator_wrapper
@@ -613,7 +679,7 @@ class MPDClient:
613
679
  amount = int(obj['binary'])
614
680
  try:
615
681
  obj['data'] = self._read_binary(amount)
616
- except IOError as err:
682
+ except OSError as err:
617
683
  raise ConnectionError(f'Error reading binary content: {err}') from err
618
684
  data_bytes = len(obj['data'])
619
685
  if data_bytes != amount: # can we ever get there?
@@ -659,7 +725,7 @@ class MPDClient:
659
725
  sock.settimeout(self.mpd_timeout)
660
726
  sock.connect(path)
661
727
  sock.settimeout(self.socket_timeout)
662
- except socket.error as socket_err:
728
+ except OSError as socket_err:
663
729
  raise ConnectionError(socket_err) from socket_err
664
730
  return sock
665
731
 
@@ -673,20 +739,20 @@ class MPDClient:
673
739
  gai = socket.getaddrinfo(host, port, socket.AF_UNSPEC,
674
740
  socket.SOCK_STREAM, socket.IPPROTO_TCP,
675
741
  flags)
676
- except socket.error as gaierr:
742
+ except OSError as gaierr:
677
743
  raise ConnectionError(gaierr) from gaierr
678
744
  for res in gai:
679
745
  af, socktype, proto, _, sa = res
680
746
  sock = None
681
747
  try:
682
- log.debug('opening socket %s', sa)
748
+ logger.debug('opening socket %s', sa)
683
749
  sock = socket.socket(af, socktype, proto)
684
750
  sock.settimeout(self.mpd_timeout)
685
751
  sock.connect(sa)
686
752
  sock.settimeout(self.socket_timeout)
687
753
  return sock
688
- except socket.error as socket_err:
689
- log.debug('opening socket %s failed: %s', sa, socket_err)
754
+ except OSError as socket_err:
755
+ logger.debug('opening socket %s failed: %s', sa, socket_err)
690
756
  err = socket_err
691
757
  if sock is not None:
692
758
  sock.close()
@@ -723,17 +789,17 @@ class MPDClient:
723
789
  host = self.host
724
790
  else:
725
791
  self.host = host
726
- if not port:
792
+ if port is None:
727
793
  port = self.port
728
794
  else:
729
795
  self.port = port
730
796
  if self._sock is not None:
731
797
  raise ConnectionError("Already connected")
732
- if host[0] in ['/', '@']:
733
- log.debug('Connecting unix socket %s', host)
798
+ if host and host[0] in ['/', '@']:
799
+ logger.debug('Connecting unix socket %s', host)
734
800
  self._sock = self._connect_unix(host)
735
801
  else:
736
- log.debug('Connecting tcp socket %s:%s (timeout: %ss)', host, port, self.mpd_timeout)
802
+ logger.debug('Connecting tcp socket %s:%s (timeout: %ss)', host, port, self.mpd_timeout)
737
803
  self._sock = self._connect_tcp(host, port)
738
804
  self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
739
805
  self._rbfile = self._sock.makefile("rb")
@@ -743,12 +809,12 @@ class MPDClient:
743
809
  except:
744
810
  self.disconnect()
745
811
  raise
746
- log.debug('Connected')
812
+ logger.debug('Connected')
747
813
 
748
814
  @property
749
815
  def socket_timeout(self):
750
816
  """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`).
751
- Use :py:obj:`None` to disable socket timout.
817
+ Use :py:obj:`None` to disable socket timeout.
752
818
 
753
819
  :setter: Set the socket timeout (integer > 0)
754
820
  :type: int or None
@@ -7,9 +7,9 @@ description = "An MPD (Music Player Daemon) client library written in pure Pytho
7
7
  authors = [
8
8
  { name="Kaliko Jack", email="kaliko@azylum.org" },
9
9
  ]
10
- license = "LGPL-3.0-or-later"
10
+ license-files = ["LICENSE.txt"]
11
11
  readme = "README.rst"
12
- requires-python = ">=3.6"
12
+ requires-python = ">=3.11"
13
13
  classifiers = [
14
14
  "Development Status :: 5 - Production/Stable",
15
15
  "Intended Audience :: Developers",
@@ -21,7 +21,10 @@ classifiers = [
21
21
  dynamic = ["version"]
22
22
 
23
23
  [project.optional-dependencies]
24
- sphinx = ["Sphinx>=5.3.0"]
24
+ sphinx = [
25
+ "Sphinx>=5.3.0",
26
+ "sphinx-rtd-theme>=3.1.0",
27
+ ]
25
28
 
26
29
  [project.urls]
27
30
  "Homepage" = "https://kaliko.me/python-musicpd/"
@@ -35,3 +38,9 @@ py-modules = ["musicpd"]
35
38
 
36
39
  [tool.setuptools.dynamic]
37
40
  version = {attr = "musicpd.VERSION"}
41
+
42
+ [dependency-groups]
43
+ test = [
44
+ "pytest>=7.0.1",
45
+ "pytest-cov>=4.0.0",
46
+ ]
@@ -1,9 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-musicpd
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: An MPD (Music Player Daemon) client library written in pure Python.
5
5
  Author-email: Kaliko Jack <kaliko@azylum.org>
6
- License-Expression: LGPL-3.0-or-later
7
6
  Project-URL: Homepage, https://kaliko.me/python-musicpd/
8
7
  Keywords: mpd,Music Player Daemon
9
8
  Classifier: Development Status :: 5 - Production/Stable
@@ -12,11 +11,12 @@ Classifier: Natural Language :: English
12
11
  Classifier: Operating System :: OS Independent
13
12
  Classifier: Programming Language :: Python :: 3
14
13
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
- Requires-Python: >=3.6
14
+ Requires-Python: >=3.11
16
15
  Description-Content-Type: text/x-rst
17
16
  License-File: LICENSE.txt
18
17
  Provides-Extra: sphinx
19
18
  Requires-Dist: Sphinx>=5.3.0; extra == "sphinx"
19
+ Requires-Dist: sphinx-rtd-theme>=3.1.0; extra == "sphinx"
20
20
  Dynamic: license-file
21
21
 
22
22
  Music Player Daemon client module
@@ -30,5 +30,5 @@ An MPD (Music Player Daemon) client library written in pure Python.
30
30
  :MPD Protocol: https://www.musicpd.org/doc/html/protocol.html
31
31
  :Code: https://gitlab.com/kaliko/python-musicpd
32
32
  :Dependencies: None
33
- :Compatibility: Python 3.6+
33
+ :Compatibility: Python 3.11+
34
34
  :Licence: GNU LGPLv3
@@ -1,3 +1,4 @@
1
1
 
2
2
  [sphinx]
3
3
  Sphinx>=5.3.0
4
+ sphinx-rtd-theme>=3.1.0
File without changes